Commit 7f57426c authored by leviw@chromium.org's avatar leviw@chromium.org

Add hung bots to sheriff-o-matic view

Add another category of failure cards that display bots that have
been running or offline over a threshold of time. This is special-
cased for chromium.webkit & blink to start.

Also removing ct-revision-details since it's effectively obsoleted
by this change.

BUG=399957
NOTRY=true

Review URL: https://codereview.chromium.org/555263004

git-svn-id: svn://svn.chromium.org/blink/trunk@181700 bbb929c8-8fbe-4397-9dbb-9b2b20218538
parent 0d8d677e
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<script>
function CTBuilderFailureGroupData(failure, bot, url) {
this.bot = bot;
this.failure = failure;
this.category = 'builder';
this.url = url;
};
CTBuilderFailureGroupData.prototype.getAnnotations = function() {
return [this.failure.annotations()];
};
// FIXME: This function should be replaced and callers should just pass the original object.
CTBuilderFailureGroupData.prototype.dataToExamine = function() {
return {url: this.url};
};
CTBuilderFailureGroupData.prototype.failureKeys = function() {
return [this.failure.keys()];
};
</script>
...@@ -11,6 +11,9 @@ found in the LICENSE file. ...@@ -11,6 +11,9 @@ found in the LICENSE file.
function CTBuilderList(failures) { function CTBuilderList(failures) {
this.builders = []; this.builders = [];
if (!Array.isArray(failures))
failures = [failures];
var builderMap = {}; var builderMap = {};
failures.forEach(function(failure) { failures.forEach(function(failure) {
var results = failure.resultNodesByBuilder; var results = failure.resultNodesByBuilder;
......
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel='import' href='ct-builder-revisions.html'>
<script>
(function () {
var kExampleBuilderRevisions =
{
"Linux": {
"revisions": {
"v8": "22785",
"chromium": "287303",
"nacl": "13580",
"blink": "158544"
}
},
"Android": {
"revisions": {
"v8": "22785",
"chromium": "287303",
"nacl": "13580",
"blink": "158543"
}
}
}
window.CTBuilderRevisionsMock = function() {
return new CTBuilderRevisions(kExampleBuilderRevisions);
};
})();
</script>
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel='import' href='ct-commit.html'>
<script>
// A simple map of builder to latest processed revision.
// FIXME: Stop special casing blink.
function CTBuilderRevisions(builderData) {
Object.keys(builderData, function(builder, builderInfo) {
this[builder] = CTCommit.createIncomplete('http://src.chromium.org/viewvc/blink?rev={revision}',
parseInt(builderInfo.revisions['blink'], 10), 'blink');
}.bind(this));
}
</script>
...@@ -10,7 +10,9 @@ function CTBuilder(masterUrl, builder, firstFailingBuild, failingBuildCount) { ...@@ -10,7 +10,9 @@ function CTBuilder(masterUrl, builder, firstFailingBuild, failingBuildCount) {
this.builder = builder; this.builder = builder;
this.firstFailingBuild = firstFailingBuild; this.firstFailingBuild = firstFailingBuild;
this.failingBuildCount = failingBuildCount; this.failingBuildCount = failingBuildCount;
this.buildUrl = "{1}/builders/{2}/builds/{3}".assign( this.buildUrl = "{1}/builders/{2}".assign(
masterUrl, encodeURIComponent(builder), firstFailingBuild); masterUrl, encodeURIComponent(builder));
if (firstFailingBuild)
this.buildUrl += "/builds/" + firstFailingBuild;
} }
</script> </script>
...@@ -7,10 +7,11 @@ found in the LICENSE file. ...@@ -7,10 +7,11 @@ found in the LICENSE file.
<link rel="import" href="ct-commit-list.html"> <link rel="import" href="ct-commit-list.html">
<script> <script>
function CTFailureGroup(key, data) { function CTFailureGroup(key, data, category) {
this.key = key; this.key = key;
this.data = data; this.data = data;
this._annotation = CTFailureGroup._mergeAnnotations(data.getAnnotations()); this._annotation = CTFailureGroup._mergeAnnotations(data.getAnnotations());
this._originalCategory = category || 'default';
this._computeProperties(); this._computeProperties();
} }
...@@ -56,7 +57,7 @@ CTFailureGroup.prototype._computeProperties = function() { ...@@ -56,7 +57,7 @@ CTFailureGroup.prototype._computeProperties = function() {
if (this._failedOnce()) { if (this._failedOnce()) {
this.category = 'failedOnce'; this.category = 'failedOnce';
} else { } else {
this.category = 'default'; this.category = this._originalCategory;
} }
// FIXME: crbug.com/400397 Split into: Whole step failure, Tree closer, Test failure, Flaky tests // FIXME: crbug.com/400397 Split into: Whole step failure, Tree closer, Test failure, Flaky tests
} }
......
...@@ -9,6 +9,7 @@ found in the LICENSE file. ...@@ -9,6 +9,7 @@ found in the LICENSE file.
<link rel="import" href="ct-builder-revisions.html"> <link rel="import" href="ct-builder-revisions.html">
<link rel="import" href="ct-failure.html"> <link rel="import" href="ct-failure.html">
<link rel="import" href="ct-failure-group.html"> <link rel="import" href="ct-failure-group.html">
<link rel="import" href="ct-builder-failure-group-data.html">
<link rel="import" href="ct-sheriff-failure-group-data.html"> <link rel="import" href="ct-sheriff-failure-group-data.html">
<link rel="import" href="ct-trooper-failure-group-data.html"> <link rel="import" href="ct-trooper-failure-group-data.html">
<link rel="import" href="ct-commit-list.html"> <link rel="import" href="ct-commit-list.html">
...@@ -16,7 +17,7 @@ found in the LICENSE file. ...@@ -16,7 +17,7 @@ found in the LICENSE file.
<script> <script>
function CTFailures(commitLog) { function CTFailures(commitLog) {
this.commitLog = commitLog; this.commitLog = commitLog;
this.builderLatestRevisions = null; this.builderLatestInfo = null;
// Maps a tree id to an array of CTFailureGroups within that tree. // Maps a tree id to an array of CTFailureGroups within that tree.
this.failures = null; this.failures = null;
this.lastUpdateDate = null; this.lastUpdateDate = null;
...@@ -93,8 +94,11 @@ CTFailures.prototype.update = function() { ...@@ -93,8 +94,11 @@ CTFailures.prototype.update = function() {
var sheriff_data = data_array[1]; var sheriff_data = data_array[1];
var trooper_data = data_array[2]; var trooper_data = data_array[2];
// FIXME: Don't special-case the blink master. this.builderLatestInfo = {};
this.builderLatestRevisions = new CTBuilderRevisions(sheriff_data.latest_builder_info['chromium.webkit']);
Object.keys(sheriff_data.latest_builder_info, function (master, data) {
this.builderLatestInfo[master] = data
}.bind(this));
var newFailures = {}; var newFailures = {};
this.lastUpdateDate = new Date(sheriff_data.date * 1000); this.lastUpdateDate = new Date(sheriff_data.date * 1000);
this._mungeAlerts(sheriff_data.alerts); this._mungeAlerts(sheriff_data.alerts);
...@@ -110,9 +114,12 @@ CTFailures.prototype.update = function() { ...@@ -110,9 +114,12 @@ CTFailures.prototype.update = function() {
}.bind(this)); }.bind(this));
// Sort failure groups so that newer failures are shown at the top // Sort failure groups so that newer failures are shown at the top
// of the UI. // of the UI.
Object.keys(newFailures, function (tree, failuresByTree) { Object.keys(newFailures, function (tree, failuresByTree) {
failuresByTree.sort(this._failureByTreeListComparator.bind(this, tree)); failuresByTree.sort(this._failureByTreeListComparator.bind(this, tree));
}.bind(this)); }.bind(this));
this._checkBuildersForFailures(newFailures);
this.failures = updateUtil.updateLeft(this.failures, newFailures); this.failures = updateUtil.updateLeft(this.failures, newFailures);
this._processTrooperFailures(trooper_data); this._processTrooperFailures(trooper_data);
}.bind(this)); }.bind(this));
...@@ -201,5 +208,41 @@ CTFailures.prototype._processFailuresForRangeGroup = function(newFailures, range ...@@ -201,5 +208,41 @@ CTFailures.prototype._processFailuresForRangeGroup = function(newFailures, range
}.bind(this)); }.bind(this));
}; };
CTFailures.prototype._checkBuildersForFailures = function(newFailures) {
var timeThreshold = {
"idle": null, // FIXME: We should alert if a bot is idle with jobs queued
"building": 3 * 60 * 60,
"offline": 0.5 * 60 * 60
};
Object.keys(this.builderLatestInfo, function(master, builders) {
// FIXME: Don't special case blink and chromium.webkit
if (master == "chromium.webkit") {
var tree = "blink";
Object.keys(builders, function(builder, builderInfo) {
var timeSinceLastUpdate = (this.lastUpdateDate.valueOf() / 1000) - builderInfo.lastUpdateTime;
var alert;
// This alert logic should live on the buildbot.
if (timeSinceLastUpdate > timeThreshold[builderInfo.state] && builderInfo.state != "idle")
alert = (timeSinceLastUpdate / (60 * 60)).toFixed(2) + " hours ";
var resultsByBuilder = { };
resultsByBuilder[builder] = {
"actual": "UNKNOWN",
"masterUrl": "http://build.chromium.org/p/" + master
}
if (alert) {
if (!newFailures[tree])
newFailures[tree] = [];
var key = master + "::" + builder + "::" + builderInfo.state;
var failure = new CTFailure(builderInfo.state, alert, resultsByBuilder);
var data = new CTBuilderFailureGroupData(failure, builder, "http://build.chromium.org/p/" + master + "/builders/" + builder);
newFailures[tree].push(new CTFailureGroup(key, data, 'builders'));
}
}.bind(this));
}
}.bind(this));
};
})(); })();
</script> </script>
...@@ -10,7 +10,7 @@ found in the LICENSE file. ...@@ -10,7 +10,7 @@ found in the LICENSE file.
function CTSheriffFailureGroupData(failures, commitList) { function CTSheriffFailureGroupData(failures, commitList) {
this.failures = failures; this.failures = failures;
this.commitList = commitList; this.commitList = commitList;
this.type = 'sheriff'; this.category = 'sheriff';
}; };
CTSheriffFailureGroupData.prototype.getAnnotations = function() { CTSheriffFailureGroupData.prototype.getAnnotations = function() {
......
...@@ -11,6 +11,7 @@ function CTTrooperFailureGroupData(details, url, raw, type, tree) { ...@@ -11,6 +11,7 @@ function CTTrooperFailureGroupData(details, url, raw, type, tree) {
this.data = raw; this.data = raw;
this.type = type; this.type = type;
this.tree = tree; this.tree = tree;
this.category = 'trooper';
}; };
CTTrooperFailureGroupData.prototype.getAnnotations = function() { CTTrooperFailureGroupData.prototype.getAnnotations = function() {
......
...@@ -398,6 +398,55 @@ describe('ct-failures', function() { ...@@ -398,6 +398,55 @@ describe('ct-failures', function() {
}); });
}); });
describe('checkBuildersForFailures', function() {
it('should generate alerts for hung bots', function() {
var analyzer = new CTFailures(new CTCommitList(undefined, []));
analyzer.builderLatestInfo =
{
'chromium.webkit': {
'Linux Tests': {
state: "building",
lastUpdateTime: 5000,
revisions: {
v8: 50,
chromium: 293001,
nacl: 50,
blink: 181262
},
},
'Win Tests': {
state: "building",
lastUpdateTime: 1,
revisions: {
v8: 50,
chromium: 293001,
nacl: 50,
blink: 181262
},
},
'Win ASAN': {
state: "offline",
lastUpdateTime: 5000,
revisions: {
v8: 50,
chromium: 293001,
nacl: 50,
blink: 181262
},
},
},
}
analyzer.lastUpdateDate = (3.5 * 60 * 60 * 1000) + 1;
var newFailures = {};
analyzer._checkBuildersForFailures(newFailures);
assert.ok('blink' in newFailures);
var blinkFailures = newFailures['blink'];
assert.lengthOf(blinkFailures, 2);
assert.equal(blinkFailures[0].key, "chromium.webkit::Win Tests::building");
assert.equal(blinkFailures[1].key, "chromium.webkit::Win ASAN::offline");
});
});
}); });
})() })()
......
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel="import" href="ct-builder-grid.html">
<link rel="import" href="ct-commit-list.html">
<link rel="import" href="ct-test-list.html">
<polymer-element name="ct-builder-failure-card" attributes="group builderList" noscript>
<template>
<style>
ct-builder-grid {
margin-right: 10px;
width: 250px;
}
#failure {
flex: 1;
}
</style>
<ct-builder-grid builderList="{{ builderList }}"></ct-builder-grid>
<div id="failure">
<template if="{{ group.failure.step == 'building' }}">
Running
</template>
<template if="{{ group.failure.step == 'offline' }}">
Offline
</template>
for {{ group.failure.testName }}
</div>
</template>
</polymer-element>
...@@ -6,6 +6,7 @@ found in the LICENSE file. ...@@ -6,6 +6,7 @@ found in the LICENSE file.
<link rel="import" href="../model/ct-builder-list.html"> <link rel="import" href="../model/ct-builder-list.html">
<link rel="import" href="ct-bot-failure-card.html"> <link rel="import" href="ct-bot-failure-card.html">
<link rel="import" href="ct-builder-failure-card.html">
<link rel="import" href="ct-trooper-card.html"> <link rel="import" href="ct-trooper-card.html">
<link rel="import" href="../bower_components/paper-dialog/paper-dialog.html"> <link rel="import" href="../bower_components/paper-dialog/paper-dialog.html">
<link rel="import" href="../bower_components/paper-dialog/paper-dialog-transition.html"> <link rel="import" href="../bower_components/paper-dialog/paper-dialog-transition.html">
...@@ -67,10 +68,14 @@ found in the LICENSE file. ...@@ -67,10 +68,14 @@ found in the LICENSE file.
</style> </style>
<div id="container"> <div id="container">
<div class="card {{ { snoozed: group.isSnoozed } | tokenList }}"> <div class="card {{ { snoozed: group.isSnoozed } | tokenList }}">
<template if="{{ group.data.type == 'sheriff' }}"> <!-- FIXME: Refactor the buttons into their own widget so we don't need cards within cards -->
<template if="{{ group.data.category == 'sheriff' }}">
<ct-bot-failure-card class='card' group="{{ group.data }}" builderList="{{ _builderList }}"></ct-bot-failure-card> <ct-bot-failure-card class='card' group="{{ group.data }}" builderList="{{ _builderList }}"></ct-bot-failure-card>
</template> </template>
<template if="{{ group.data.type != 'sheriff' }}"> <template if="{{ group.data.category == 'builder' }}">
<ct-builder-failure-card class='card' group="{{ group.data }}" builderList="{{ _builderList }}"></ct-builder-failure-card>
</template>
<template if="{{ group.data.category == 'trooper' }}">
<ct-trooper-card class='card' group="{{ group.data }}"></ct-trooper-card> <ct-trooper-card class='card' group="{{ group.data }}"></ct-trooper-card>
</template> </template>
</div> </div>
...@@ -109,6 +114,7 @@ found in the LICENSE file. ...@@ -109,6 +114,7 @@ found in the LICENSE file.
group: '_updateCommitList', group: '_updateCommitList',
commitLog: '_updateCommitList', commitLog: '_updateCommitList',
'group.data.failures': '_updateBuilderList', 'group.data.failures': '_updateBuilderList',
'group.data.failure': '_updateBuilderList',
}, },
examine: function() { examine: function() {
...@@ -129,8 +135,10 @@ found in the LICENSE file. ...@@ -129,8 +135,10 @@ found in the LICENSE file.
}, },
_updateBuilderList: function() { _updateBuilderList: function() {
if (this.group.data.type == 'sheriff') if (this.group.data.category == 'sheriff')
this._builderList = new CTBuilderList(this.group.data.failures); this._builderList = new CTBuilderList(this.group.data.failures);
else if (this.group.data.category == 'builder')
this._builderList = new CTBuilderList(this.group.data.failure);
}, },
linkBug: function() { linkBug: function() {
......
...@@ -96,7 +96,11 @@ found in the LICENSE file. ...@@ -96,7 +96,11 @@ found in the LICENSE file.
// which means that two failing tests from different test suites conflict! // which means that two failing tests from different test suites conflict!
var testPart = result.actual == 'UNKNOWN' ? 'stdio' : this.failure.testName.split('.')[1]; var testPart = result.actual == 'UNKNOWN' ? 'stdio' : this.failure.testName.split('.')[1];
var url = result.masterUrl + var url = result.masterUrl +
'/builders/' + encodeURIComponent(this.builder) + '/builders/' + encodeURIComponent(this.builder);
// FIXME: Make failure groups aware of their own url
if (this.failure.testName)
url +=
'/builds/' + result.lastFailingBuild + '/builds/' + result.lastFailingBuild +
'/steps/' + this.failure.step + '/steps/' + this.failure.step +
'/logs/' + testPart; '/logs/' + testPart;
......
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link href="../bower_components/core-icons/core-icons.html" rel="import">
<link href="../bower_components/paper-item/paper-item.html" rel="import">
<link href="../model/ct-builder-revisions.html" rel="import">
<link href="ct-popup-menu.html" rel="import">
<polymer-element name="ct-revision-details" attributes="builderLatestRevisions commitLog tree">
<template>
<style>
ct-popup-menu > div {
display: flex;
justify-content: space-between;
}
.menuRevision {
padding-left: 2em;
}
</style>
<!-- FIXME: Stop special casing the blink tree. -->
<template if="{{ _sortedBuilders.length && tree == 'blink' }}">
Latest revision processed by every bot:
<a id="fullyProcessedRevision" href="{{ _fullyProcessedRevision.url }}">{{ _fullyProcessedRevision.revision }}</a>
<ct-popup-menu icon="arrow-drop-down">
<template repeat="{{builder in _sortedBuilders}}">
<div>
<div>
{{ builder }}
</div>
<div class="menuRevision">
<a href="{{ builderLatestRevisions[builder].url }}">{{ builderLatestRevisions[builder].revision }}</a>
</div>
</div>
</template>
</ct-popup-menu>
trunk is at <a id="trunkRevision" href="{{ commitLog.commits.blink[commitLog.lastRevision['blink']].url }}">{{ commitLog.lastRevision['blink'] }}</a>
</template>
</template>
<script>
Polymer({
_sortedBuilders: [],
_fullyProcessedRevision: null,
builderLatestRevisions: null,
builderLatestRevisionsChanged: function() {
// Get the list of builders sorted with the most out-of-date one first.
var sortedBuilders = Object.keys(this.builderLatestRevisions);
sortedBuilders.sort(function (a, b) { return this.builderLatestRevisions[a].revision - this.builderLatestRevisions[b].revision;}.bind(this));
this._sortedBuilders = sortedBuilders;
this._fullyProcessedRevision = this.builderLatestRevisions[sortedBuilders[0]];
},
});
</script>
</polymer-element>
...@@ -9,7 +9,6 @@ found in the LICENSE file. ...@@ -9,7 +9,6 @@ found in the LICENSE file.
<link rel="import" href="ct-failure-stream.html"> <link rel="import" href="ct-failure-stream.html">
<link rel="import" href="ct-last-updated.html"> <link rel="import" href="ct-last-updated.html">
<link rel="import" href="ct-party-time.html"> <link rel="import" href="ct-party-time.html">
<link rel="import" href="ct-revision-details.html">
<link rel="import" href="ct-tree-status.html"> <link rel="import" href="ct-tree-status.html">
<polymer-element name="ct-unexpected-failures" attributes="tree commitLog failures"> <polymer-element name="ct-unexpected-failures" attributes="tree commitLog failures">
...@@ -34,13 +33,8 @@ found in the LICENSE file. ...@@ -34,13 +33,8 @@ found in the LICENSE file.
align-items: baseline; align-items: baseline;
padding: 0 5px; padding: 0 5px;
} }
.failure-group-header {
padding: 0 5px;
}
</style> </style>
<div class="toolbar"> <div class="toolbar">
<ct-revision-details id="revisionDetails" builderLatestRevisions="{{ failures.builderLatestRevisions }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-revision-details>
<a href="https://code.google.com/p/chromium/wiki/UsefulURLs">Useful URLs</a> <a href="https://code.google.com/p/chromium/wiki/UsefulURLs">Useful URLs</a>
</div> </div>
<ct-tree-status status="{{ treeStatuses['chromium'] }}" state="{{ treeStatuses['chromium'].status }}"></ct-tree-status> <ct-tree-status status="{{ treeStatuses['chromium'] }}" state="{{ treeStatuses['chromium'].status }}"></ct-tree-status>
...@@ -48,6 +42,7 @@ found in the LICENSE file. ...@@ -48,6 +42,7 @@ found in the LICENSE file.
<template if="{{ failures && failures.failures && (!failures.failures[tree] || !failures.failures[tree].length) }}"> <template if="{{ failures && failures.failures && (!failures.failures[tree] || !failures.failures[tree].length) }}">
<ct-party-time></ct-party-time> <ct-party-time></ct-party-time>
</template> </template>
<ct-failure-stream title="Probably-hung bots" category="builders" groups="{{ failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream>
<ct-failure-stream title="Reliable failures" category="default" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream> <ct-failure-stream title="Reliable failures" category="default" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream>
<ct-failure-stream title="Failures that have only happened once (on one bot)" category="failedOnce" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream> <ct-failure-stream title="Failures that have only happened once (on one bot)" category="failedOnce" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream>
<ct-failure-stream title="Snoozed failures" category="snoozed" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream> <ct-failure-stream title="Snoozed failures" category="snoozed" groups="{{ failures && failures.failures[tree] }}" commitLog="{{ commitLog }}" tree="{{ tree }}"></ct-failure-stream>
......
<!--
Copyright 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel="import" href="../ct-revision-details.html">
<link rel="import" href="../../model/ct-builder-revisions-mock.html">
<link rel="import" href="../../model/ct-commit-log-mock.html">
<script>
(function () {
var assert = chai.assert;
describe('ct-revision-details', function() {
var revisionDetails;
describe('empty', function() {
before(function(done) {
revisionDetails = document.createElement('ct-revision-details');
setTimeout(done);
});
it('should not show revision', function() {
assert.isNull(revisionDetails.shadowRoot.querySelector('#fullyProcessedRevision'));
assert.isNull(revisionDetails.shadowRoot.querySelector('#trunkRevision'));
});
});
describe('blink revision', function() {
before(function(done) {
revisionDetails = document.createElement('ct-revision-details');
var builderRevisions = new CTBuilderRevisionsMock();
var commitLog = new CTCommitLogMock();
commitLog._updateLastRevision('blink');
revisionDetails.builderLatestRevisions = builderRevisions;
revisionDetails.tree = 'blink';
revisionDetails.commitLog = commitLog;
setTimeout(done);
});
it('should show the revision', function() {
assert.equal(revisionDetails.shadowRoot.querySelector('#fullyProcessedRevision').innerText, '158543');
assert.equal(revisionDetails.shadowRoot.querySelector('#trunkRevision').innerText, '158545');
});
});
});
})();
</script>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment