v1 UI and v2 UI should share statistics.js
[WebKit.git] / Websites / perf.webkit.org / public / index.html
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <title>Perf Monitor is Loading...</title>
5 <script src="js/jquery.js" defer></script>
6 <script src="js/jquery.flot.js" defer></script>
7 <script src="js/jquery.flot.crosshair.js" defer></script>
8 <script src="js/jquery.flot.fillbetween.js" defer></script>
9 <script src="js/jquery.flot.resize.js" defer></script>
10 <script src="js/jquery.flot.selection.js" defer></script>
11 <script src="js/jquery.flot.time.js" defer></script>
12 <script src="js/helper-classes.js" defer></script>
13 <script src="shared/statistics.js" defer></script>
14 <link rel="stylesheet" href="common.css">
15 <style type="text/css">
16
17 #numberOfDaysPicker {
18     color: #666;
19 }
20
21 #numberOfDaysPicker input {
22     height: 0.9em;
23     margin-right: 1em;
24 }
25
26 td, th {
27     border: none;
28     border-collapse: collapse;
29     padding-top: 0.5em;
30 }
31
32 #dashboard > tbody > tr > td {
33     vertical-align: top;
34 }
35
36 #dashboard > thead th {
37     text-shadow: #bbb 1px 1px 2px;
38     font-size: large;
39     font-weight: normal;
40 }
41
42 .chart {
43     position: relative;
44     border: solid 1px #ccc;
45     border-radius: 5px;
46     margin: 0px 0px 10px 0px;
47 }
48
49 .chart.worse {
50     background: #fcc;
51 }
52
53 .chart.better {
54     background: #cfc;
55 }
56
57 #charts .pane {
58     position: absolute;
59     left: 10px;
60     top: 10px;
61     width: 230px;
62 }
63
64 .chart header {
65     height: 3em;
66     display: table-cell;
67     vertical-align: middle;
68 }
69
70 #dashboard header {
71     padding: 0px 10px;
72 }
73
74 .chart h2, .chart h3 {
75     margin: 0 0 0.3em 0;
76     padding: 0;
77     font-size: 1em;
78     font-weight: normal;
79     word-break: break-all;
80 }
81
82 .chart h2 {
83     font-size: normal;
84 }
85
86 .chart h3 {
87     font-size: normal;
88 }
89
90 #dashboard .chart h3 {
91     display: none;
92 }
93
94 #dashboard .chart .status {
95     margin: 0px 10px;
96 }
97
98 .chart .status {
99     font-size: small;
100     color: #666;
101 }
102
103 .plot {
104     margin: 5px 5px 10px 5px;
105 }
106
107 .closeButton svg {
108     position: absolute;
109     left: 8px;
110     bottom: 8px;
111     width: 15px;
112     height: 15px;
113 }
114
115 #dashboard .chart {
116     width: 410px;
117 }
118
119 #dashboard .plot {
120     width: 400px;
121     height: 100px;
122     cursor: pointer;
123     cursor: hand;
124 }
125
126 #charts .plot {
127     height: 320px;
128     margin-left: 250px;
129 }
130
131 #dashboard .overviewPlot {
132     display: none;
133 }
134
135 #charts .overviewPlot {
136     margin: 10px 0px 0px 0px;
137     padding: 0;
138     height: 70px;
139 }
140
141 .chart .summaryTable {
142     font-size: small;
143     color: #666;
144     border: 0;
145 }
146
147 .chart .meta {
148     position: relative;
149 }
150
151 .chart .meta table,
152 .chart .meta td,
153 .tooltip table,
154 .tooltip td {
155     margin: 0;
156     padding: 0;
157 }
158
159 .chart .meta th,
160 .tooltip th {
161     margin: 0;
162     padding: 0 0.2em 0 0;
163     text-align: right;
164     font-weight: normal;
165 }
166
167 .chart .meta td:not(:first-child):before,
168 .tooltip td:not(:first-child):before {
169     content: ": ";
170 }
171
172 #dashboard .chart .summaryTable {
173     position: absolute;
174     right: 10px;
175     top: 0px;
176 }
177
178 #charts .summaryTable {
179     margin-top: 0.3em;
180 }
181
182 #dashboard .arrow {
183     width: 20px;
184     height: 40px;
185     position: absolute;
186     bottom: 50px;
187     left: 5px;
188 }
189
190 #charts .arrow {
191     width: 20px;
192     height: 40px;
193     position: absolute;
194     top: 10px;
195     left: 240px;
196 }
197
198 .chart svg {
199     stroke: #ccc;
200     fill: #ccc;
201     color: #ccc;
202 }
203
204 .chart.worse svg {
205     stroke: #c99;
206     fill: #c99;
207     color: #c99;
208 }
209
210 .chart.better svg {
211     stroke: #9c9;
212     fill: #9c9;
213     color: #9c9;
214 }
215
216 .chart .yaxistoggler {
217     position: absolute;
218     bottom: 10px;
219     left: 225px;
220     width: 10px;
221     height: 25px;
222 }
223
224 #dashboard  .yaxistoggler {
225     display: none;
226 }
227
228 .tooltip {
229     position: relative;
230     border-radius: 5px;
231     padding: 5px;
232     opacity: 0.8;
233     background: #333;
234     color: #eee;
235     font-size: small;
236     line-height: 130%;
237     white-space: nowrap;
238 }
239
240 .tooltip:before {
241     position: absolute;
242     width: 0;
243     height: 0;
244     left: 50%;
245     margin-left: -9px;
246     top: -19px;
247     content: "";
248     display: none;
249     border-style: solid;
250     border-width: 10px;
251     border-color: transparent transparent #333 transparent;
252 }
253
254 .tooltip.inverted:before {
255     display: block;
256 }
257
258 .tooltip.inverted:after {
259     display: none;
260 }
261
262 .tooltip:after {
263     position: absolute;
264     width: 0;
265     height: 0;
266     left: 50%;
267     margin-left: -9px;
268     bottom: -19px;
269     content: "";
270     display: block;
271     border-style: solid;
272     border-width: 10px;
273     border-color: #333 transparent transparent transparent;
274 }
275
276 .tooltip a {
277     text-decoration: underline;
278     color: #fff;
279     text-shadow: none;
280 }
281
282 .clickTooltip {
283     opacity: 0.6;
284 }
285
286 .hoverTooltip {
287     z-index: 99999;
288 }
289
290 #testPicker {
291     display: inline-block;
292     margin: 10px 0px;
293     border: solid 1px #ccc;
294     color: #666;
295     border-radius: 5px;
296     padding: 5px 8px;
297 }
298
299 </style>
300 <script>
301
302 (function () {
303     var charts = [];
304     var minTime;
305     var currentZoom;
306     var sharedPlotOptions = {
307         lines: { show: true, lineWidth: 1 },
308         xaxis: {
309             mode: "time",
310             timeformat: "%m/%d",
311             minTickSize: [1, 'day'],
312             max: Date.now(), // FIXME: This is likely broken for non-PST
313         },
314         yaxis: { tickLength: 0 },
315         series: { shadowSize: 0 },
316         points: { show: false },
317         grid: {
318             hoverable: true,
319             borderWidth: 1,
320             borderColor: '#999',
321             backgroundColor: '#fff',
322         }
323     };
324
325     function adjustedIntervalForRun(results, minTime, minRatioToFitInAdjustedInterval) {
326         if (!results)
327             return {min: Number.MAX_VALUE, max: Number.MIN_VALUE};
328         var degreeOfWeightingDecrease = 0.2;
329         var movingAverage = results.exponentialMovingArithmeticMean(minTime, degreeOfWeightingDecrease);
330         var resultsCount = results.countResults(minTime);
331         var adjustmentDelta = results.sampleStandardDeviation(minTime) / 4;
332         var adjustedMin = movingAverage;
333         var adjustedMax = movingAverage;
334         var adjustmentCount;
335         for (adjustmentCount = 0; adjustmentCount < 4 * 4; adjustmentCount++) { // Don't expand beyond 4 standard deviations.
336             adjustedMin -= adjustmentDelta;
337             adjustedMax += adjustmentDelta;
338             if (results.countResultsInInterval(minTime, adjustedMin, adjustedMax) / resultsCount >= minRatioToFitInAdjustedInterval)
339                 break;
340         }
341         for (var i = 0; i < adjustmentCount; i++) {
342             if (results.countResultsInInterval(minTime, adjustedMin + adjustmentDelta, adjustedMax) / resultsCount < minRatioToFitInAdjustedInterval)
343                 break;
344             adjustedMin += adjustmentDelta;
345         }
346         for (var i = 0; i < adjustmentCount; i++) {
347             if (results.countResultsInInterval(minTime, adjustedMin, adjustedMax - adjustmentDelta) / resultsCount < minRatioToFitInAdjustedInterval)
348                 break;
349             adjustedMax -= adjustmentDelta;
350         }
351         return {min: adjustedMin, max: adjustedMax};
352     }
353
354     function computeYAxisBoundsToFitLines(minTime, results, baseline, target) {
355         var minOfAllRuns = results.min(minTime);
356         var maxOfAllRuns = results.max(minTime);
357         if (baseline) {
358             minOfAllRuns = Math.min(minOfAllRuns, baseline.min(minTime));
359             maxOfAllRuns = Math.max(maxOfAllRuns, baseline.max(minTime));
360         }
361         if (target) {
362             minOfAllRuns = Math.min(minOfAllRuns, target.min(minTime));
363             maxOfAllRuns = Math.max(maxOfAllRuns, target.max(minTime));
364         }
365         var marginSize = (maxOfAllRuns - minOfAllRuns) * 0.1;
366
367         var minRatioToFitInAdjustedInterval = 0.9;
368         var intervalForResults = adjustedIntervalForRun(results, minTime, minRatioToFitInAdjustedInterval);
369         var intervalForBaseline = adjustedIntervalForRun(baseline, minTime, minRatioToFitInAdjustedInterval);
370         var intervalForTarget = adjustedIntervalForRun(target, minTime, minRatioToFitInAdjustedInterval);
371         var adjustedMin = Math.min(intervalForResults.min, intervalForBaseline.min, intervalForTarget.min);
372         var adjustedMax = Math.max(intervalForResults.max, intervalForBaseline.max, intervalForTarget.max);
373         var adjsutedMarginSize = (adjustedMax - adjustedMin) * 0.1;
374         return {min: minOfAllRuns - marginSize, max: maxOfAllRuns + marginSize,
375             adjustedMin: Math.max(minOfAllRuns - marginSize, adjustedMin - adjsutedMarginSize),
376             adjustedMax: Math.min(maxOfAllRuns + marginSize, adjustedMax + adjsutedMarginSize)};
377     }
378
379     function computeStatus(smallerIsBetter, lastResult, baseline, target) {
380         var relativeDifferenceWithBaseline = baseline ? lastResult.relativeDifference(baseline.lastResult()) : 0;
381         var relativeDifferenceWithTarget = target ? lastResult.relativeDifference(target.lastResult()) : 0;
382         var statusText = '';
383         var status = '';
384
385         if (relativeDifferenceWithBaseline && relativeDifferenceWithBaseline > 0 != smallerIsBetter) {
386             statusText = Math.abs(relativeDifferenceWithBaseline * 100).toFixed(2) + '% ' + (smallerIsBetter ? 'above' : 'below') + ' baseline';
387             status = 'worse';
388         } else if (relativeDifferenceWithTarget && relativeDifferenceWithTarget > 0 == smallerIsBetter) {
389             statusText = Math.abs(relativeDifferenceWithTarget * 100).toFixed(2) + '% ' + (smallerIsBetter ? 'below' : 'above') + ' target';
390             status = 'better';
391         } else if (relativeDifferenceWithTarget)
392             statusText = Math.abs(relativeDifferenceWithTarget * 100).toFixed(2) + '% until target';
393
394         return {class: status, text: statusText};
395     }
396
397     function addPlotDataForRun(plotData, name, runs, color, interactive) {
398         var entry = {color: color, data: runs.meanPlotData()};
399         if (!interactive) {
400             entry.clickable = false;
401             entry.hoverable = false;
402         }
403         if (runs.hasConfidenceInterval()) {
404             var lowerName = name.toLowerCase();
405             var confienceEntry = $.extend(true, {}, entry, {lines: {lineWidth: 0}, clickable: false, hoverable: false});
406             plotData.push($.extend(true, {}, confienceEntry, {id: lowerName, data: runs.upperConfidencePlotData()}));
407             plotData.push($.extend(true, {}, confienceEntry,
408                 {fillBetween: lowerName, lines: {fill: 0.3}, data: runs.lowerConfidencePlotData()}));
409         }
410         plotData.push(entry);
411     }
412
413     function createSummaryRowMarkup(name, runs) {
414         return '<tr><th>' + name + '</th><td>' + runs.lastResult().label() + '</td></tr>';
415     }
416
417     function buildLabelWithLinks(build, previousBuild) {
418         function linkifyIfNotNull(label, url) {
419             return url ? '<a href="' + url + '" target="_blank">' + label + '</a>' : label;
420         }
421
422         var formattedRevisions = build.formattedRevisions(previousBuild);
423         var buildInfo = {
424             'Commit': build.formattedTime(),
425             'Build': linkifyIfNotNull(build.buildNumber(), build.buildUrl()) + '(' + build.formattedBuildTime() + ')'
426         };
427         for (var repositoryName in formattedRevisions)
428             buildInfo[repositoryName] = linkifyIfNotNull(formattedRevisions[repositoryName].label, formattedRevisions[repositoryName].url);
429         var markup = '';
430         for (var key in buildInfo)
431             markup += '<tr><th>' + key + '</th><td>' + buildInfo[key] + '</td>';
432         return '<tr>' + markup + '</tr>';
433     }
434
435     function Chart(container, isDashboard, platform, metric, bugTrackers, onClose) {
436         var linkifiedFullName = metric.fullName;
437         if (metric.test.url)
438             linkifiedFullName = '<a href="' + metric.test.url + '">' + linkifiedFullName + '</a>';
439         var section = $('<section class="chart"><div class="pane"><header><h2>' + linkifiedFullName + '</h2>'
440             + '<h3 class="platform">' + platform.name + '</h3></header>'
441             + '<div class="meta"><table class="status"><tbody></tbody></table><table class="summaryTable"><tbody></tbody></table></div>'
442             + '<div class="overviewPlot"></div></div>'
443             + '<div class="plot"></div>'
444             + '<div class="unit"></div>'
445             + '<svg viewBox="0 0 20 100" class="arrow">'
446             + '<g stroke-width="10"><line x1="10" y1="8" x2="10" y2="92" />'
447             + '<polygon points="5,85 15,85 10,90" class="downwardArrowHead" />'
448             + '<polygon points="5,15 15,15 10,10" class="upwardArrowHead" />'
449             + '</g></svg>'
450             + '<a href="#" class="toggleYAxis"><svg viewBox="0 0 40 100" class="yaxistoggler"><g stroke-width="10">'
451             + '<line x1="20" y1="8" x2="20" y2="82" />'
452             + '<polygon points="15,15 25,15 20,10" />'
453             + '<polygon points="15,75 25,75 20,80" />'
454             + '<line x1="0" y1="92" x2="40" y2="92" />'
455             + '</g></svg></a>'
456             + '<a href="#" class="closeButton"><svg viewBox="0 0 100 100">'
457             + '<g stroke-width="10"><circle cx="50" cy="50" r="45" fill="transparent"/><polygon points="30,30 70,70" />'
458             + '<polygon points="30,70 70,30" /></g></svg></a></section>');
459
460         $(container).append(section);
461
462         var self = this;
463         if (onClose) {
464             section.find('.closeButton').bind('click', function (event) {
465                 event.preventDefault();
466                 section.remove();
467                 charts.splice(charts.indexOf(self), 1);
468                 onClose(self);
469                 return false;
470             });
471         } else
472             section.find('.closeButton').hide();
473
474         section.find('.yaxistoggler').bind('click', function (event) {
475             self.toggleYAxis();
476             event.preventDefault();
477             return false;
478         });
479
480         var plotData = [];
481         var results;
482         var baseline;
483         var target;
484
485         var tooltip = new Tooltip(container, 'tooltip hoverTooltip');
486         var bounds;
487         var plotContainer = section.find('.plot');
488         var mainPlot;
489         var overviewPlot;
490         var clickTooltips = [];
491         var shouldShowEntireYAxis = false;
492
493         this.platform = function () { return platform; }
494         this.metric = function () { return metric; }
495
496         this.populate = function (passedResults, passedBaseline, passedTarget) {
497             results = passedResults;
498             baseline = passedBaseline;
499             target = passedTarget;
500
501             var summaryRows = '';
502             if (target) {
503                 addPlotDataForRun(plotData, 'Target', target, '#039');
504                 summaryRows = createSummaryRowMarkup('Target', target) + summaryRows;
505             }
506             if (baseline) {
507                 addPlotDataForRun(plotData, 'Baseline', baseline, '#930');
508                 summaryRows = createSummaryRowMarkup('Baseline', baseline) + summaryRows;
509             }
510             addPlotDataForRun(plotData, 'Current', results, '#666', true);
511             summaryRows = createSummaryRowMarkup('Current', results) + summaryRows;
512
513             var status = computeStatus(results.smallerIsBetter(), results.lastResult(), baseline, target);
514             if (status.text)
515                 summaryRows = '<tr><td colspan="2">' + status.text + '</td></tr>' + summaryRows;
516             section.addClass(status.class);
517             section.find('.status tbody').html(buildLabelWithLinks(results.lastResult().build()));
518             section.find('.summaryTable tbody').html(summaryRows);
519             if (results.smallerIsBetter()) {
520                 section.find('.arrow .downwardArrowHead').show();
521                 section.find('.arrow .upwardArrowHead').hide();
522             } else {
523                 section.find('.arrow .downwardArrowHead').hide();
524                 section.find('.arrow .upwardArrowHead').show();
525             }
526         }
527
528         this.attachMainPlot = function (xMin, xMax) {
529             if (!bounds)
530                 return;
531
532             var mainPlotOptions = $.extend(true, {}, sharedPlotOptions, {
533                 xaxis: {
534                     min: xMin,
535                 },
536                 yaxis: {
537                     tickLength: null,
538                     min: shouldShowEntireYAxis ? 0 : bounds.adjustedMin,
539                     max: shouldShowEntireYAxis ? bounds.max : bounds.adjustedMax,
540                 },
541                 crosshair: {'mode': 'x', color: '#c90', lineWidth: 1},
542                 grid: {clickable: true},
543             });
544             if (xMax)
545                 mainPlotOptions.xaxis.max = xMax;
546             if (isDashboard) {
547                 mainPlotOptions.yaxis.labelWidth = 20;
548                 mainPlotOptions.yaxis.ticks = [mainPlotOptions.yaxis.min, mainPlotOptions.yaxis.max];
549                 mainPlotOptions.grid.autoHighlight = false;
550             } else {
551                 mainPlotOptions.yaxis.labelWidth = 30;
552                 mainPlotOptions.selection = {mode: "x"};
553                 plotData[plotData.length - 1].points = {show: true, radius: 1};
554             }
555             mainPlot = $.plot(plotContainer, plotData, mainPlotOptions);
556             plotData[plotData.length - 1].points = {};
557
558             for (var i = 0; i < clickTooltips.length; i++) {
559                 if (clickTooltips[i])
560                     clickTooltips[i].remove();
561             }
562             clickTooltips = [];
563         }
564
565         this.toggleYAxis = function () {
566             shouldShowEntireYAxis = !shouldShowEntireYAxis;
567             if (currentZoom)
568                 this.attachMainPlot(currentZoom.from, currentZoom.to);
569             else
570                 this.attachMainPlot(minTime);
571         }
572
573         this.zoom = function (from, to) {
574             this.attachMainPlot(from, to);
575             if (overviewPlot)
576                 overviewPlot.setSelection({xaxis: {from: from, to: to}}, true);
577         }
578
579         this.clearZoom = function () {
580             this.attachMainPlot(minTime);
581             if (overviewPlot)
582                 overviewPlot.clearSelection();
583         }
584         
585         this.setCrosshair = function (pos) {
586             if (mainPlot)
587                 mainPlot.setCrosshair(pos);
588         }
589
590         this.clearCrosshair = function () {
591             if (mainPlot)
592                 mainPlot.clearCrosshair();
593         }
594
595         this.hideTooltip = function() {
596             if (tooltip)
597                 tooltip.hide();
598         }
599
600         function toggleClickTooltip(index, pageX, pageY) {
601             if (clickTooltips[index])
602                 clickTooltips[index].toggle();
603             else {
604                 // FIXME: Put this on URLState.
605                 var newTooltip = new Tooltip(container, 'tooltip clickTooltip');
606                 showTooltipWithResults(newTooltip, pageX, pageY, results.resultAt(index), results.resultAt(index - 1));
607                 newTooltip.bindClick(function () { toggleClickTooltip(index, pageX, pageY); });
608                 newTooltip.bindMouseEnter(function () { tooltip.hide(); });
609                 clickTooltips[index] = newTooltip;
610             }
611             tooltip.hide();
612         }
613
614         function showTooltipWithResults(tooltip, x, y, result, resultToCompare) {
615             var newBugUrls = '';
616             if (resultToCompare) {
617                 var title = (resultToCompare.isBetterThan(result) ? 'REGRESSION: ' : '') + result.metric().fullName
618                     + ' got ' + result.formattedProgressionOrRegression(resultToCompare)
619                     + ' around ' + result.build().formattedTime();
620                 var revisions = result.build().formattedRevisions(resultToCompare.build());
621
622                 for (var trackerId in bugTrackers) {
623                     var tracker = bugTrackers[trackerId];
624                     var repositories = tracker.repositories;
625                     var description = 'Platform: ' + result.build().platform().name + '\n\n';
626                     for (var i = 0; i < repositories.length; ++i) {
627                         var repositoryName = repositories[i];
628                         var revision = revisions[repositoryName];
629                         if (!revision)
630                             continue;
631                         if (revision.url)
632                             description += repositoryName + ': ' + revision.url;
633                         else
634                             description += revision.label;
635                         description += '\n';
636                     }
637                     var url = tracker.newBugUrl
638                         .replace(/\$title/g, encodeURIComponent(title))
639                         .replace(/\$description/g, encodeURIComponent(description))
640                         .replace(/\$link/g, encodeURIComponent(location.href));
641                     if (newBugUrls)
642                         newBugUrls += ',';
643                     newBugUrls += ' <a href="' + url + '" target="_blank">' + tracker.name + '</a>';
644                 }
645                 newBugUrls = 'File:' + newBugUrls;
646             }
647             tooltip.show(x, y, result.label(resultToCompare) + '<table>'
648                 + buildLabelWithLinks(result.build(), resultToCompare ? resultToCompare.build() : null) + '</table>'
649                 + newBugUrls);
650         }
651
652         tooltip.bindClick(function () {
653             if (tooltip.currentItem)
654                 toggleClickTooltip(tooltip.currentItem.dataIndex, tooltip.currentItem.pageX, tooltip.currentItem.pageY);
655         });
656
657         function closestItemForPageXRespectingPlotOffset(item, plot, series, pageX) {
658             if (!series || !series.data.length)
659                 return null;
660
661             var offset = $(plotContainer).offset();
662             var points = series.datapoints.points;
663             var size = series.datapoints.pointsize;
664             var xInPlot = pageX - offset.left;
665
666             if (xInPlot < plot.getPlotOffset().left)
667                 return null;
668             if (item)
669                 return item;
670
671             var previousPoint;
672             var index = 0;
673             while (1) {
674                 var currentPoint = plot.pointOffset({x: points[index * size], y: points[index * size + 1]});
675                 if (xInPlot < currentPoint.left) {
676                     if (previousPoint && xInPlot < (previousPoint.left + currentPoint.left) / 2) {
677                         index -= 1;
678                         currentPoint = previousPoint;
679                     }
680                     break;
681                 }
682                 if (index + 1 >= series.data.length)
683                     break;
684                 previousPoint = currentPoint;
685                 index++;
686             }
687
688             // Ideally we want to return a real item object but flot doesn't provide an API to obtain one
689             // so create an object that contain properties we use.
690             return {dataIndex: index, pageX: offset.left + currentPoint.left, pageY: offset.top + currentPoint.top};
691         }
692
693         // Return a plot generator. This function is called when we change the number of days to show.
694         this.attach = function () {
695             if (!results)
696                 return;
697
698             bounds = computeYAxisBoundsToFitLines(minTime, results, baseline, target);
699
700             var overviewContainer = section.find('.overviewPlot');
701             if (!isDashboard) {
702                 overviewPlot = $.plot(overviewContainer, plotData,
703                     $.extend(true, {}, sharedPlotOptions, {
704                         xaxis: {
705                             min: minTime,
706                             // The maximum number of ticks we can fit on the overflow plot is 4.
707                             tickSize: [(TestBuild.now() - minTime) / 4 / 1000, 'second']
708                         },
709                         grid: { hoverable: false, clickable: false },
710                         selection: { mode: "x" },
711                         yaxis: {
712                             show: false,
713                             min: bounds.min,
714                             max: bounds.max,
715                         }
716                 }));
717
718                 $(plotContainer).bind("plotselected", function (event, ranges) { Chart.zoom(ranges.xaxis.from, ranges.xaxis.to); });
719                 $(overviewContainer).bind("plotselected", function (event, ranges) { Chart.zoom(ranges.xaxis.from, ranges.xaxis.to); });
720                 $(overviewContainer).bind("plotunselected", function () { Chart.clearZoom(minTime) });
721             }
722
723             if (currentZoom)
724                 this.zoom(currentZoom.from, currentZoom.to);
725             else
726                 this.attachMainPlot(minTime);
727
728             if (bindPlotEventHandlers)
729                 bindPlotEventHandlers(this);
730             bindPlotEventHandlers = null;
731         };
732
733         function bindPlotEventHandlers(chart) {
734             // FIXME: Crosshair should stay where it was between charts.
735             $(plotContainer).bind("plothover", function (event, pos, item) {
736                 for (var i = 0; i < charts.length; i++) {
737                     if (charts[i] !== chart) {
738                         charts[i].setCrosshair(pos);
739                         charts[i].hideTooltip();
740                     }
741                 }
742                 if (isDashboard)
743                     return;
744                 var data = mainPlot.getData();
745                 item = closestItemForPageXRespectingPlotOffset(item, mainPlot, data[data.length - 1], pos.pageX);
746                 if (!item)
747                     return;
748
749                 showTooltipWithResults(tooltip, item.pageX, item.pageY, results.resultAt(item.dataIndex), results.resultAt(item.dataIndex - 1));
750                 tooltip.currentItem = item;
751             });
752
753             $(plotContainer).bind("mouseleave", function (event) {
754                 var offset = $(plotContainer).offset();
755                 if (offset.left <= event.pageX && offset.top <= event.pageY && event.pageX <= offset.left + $(plotContainer).outerWidth()
756                     && event.pageY <= offset.top + $(plotContainer).outerHeight())
757                     return 0;
758                 for (var i = 0; i < charts.length; i++) {
759                     charts[i].clearCrosshair();
760                     charts[i].hideTooltip();
761                 }
762
763             });
764
765             if (isDashboard) { // FIXME: This code doesn't belong here.
766                 $(plotContainer).bind('click', function (event) {
767                     openChart(results.platform(), results.metric());
768                 });
769             } else {
770                 $(plotContainer).bind("plotclick", function (event, pos, item) {
771                     if (tooltip.currentItem)
772                         toggleClickTooltip(tooltip.currentItem.dataIndex, tooltip.currentItem.pageX, tooltip.currentItem.pageY);
773                 });
774             }
775         }
776
777         charts.push(this);
778     }
779
780     Chart.clear = function () {
781         charts = [];
782     }
783
784     Chart.setMinTime = function (newMinTime) {
785         minTime = newMinTime;
786         for (var i = 0; i < charts.length; i++)
787             charts[i].attach();
788     }
789
790     Chart.onzoomchange = function (from, to) { };
791
792     Chart.zoom = function (from, to) {
793         currentZoom = {from: from, to: to};
794         for (var i = 0; i < charts.length; i++)
795             charts[i].zoom(from, to);
796         this.onzoomchange(from, to);
797     }
798
799     Chart.clearZoom = function (minTime) {
800         currentZoom = undefined;
801         for (var i = 0; i < charts.length; i++)
802             charts[i].clearZoom();
803         this.onzoomchange();
804     }
805
806     window.Chart = Chart;
807 })();
808
809 // FIXME: We need to devise a way to fetch runs in multiple chunks so that
810 // we don't have to fetch the entire time series to just show the last 3 days.
811 // FIXME: We should do a mass-fetch where we fetch JSONs for multiple runs at once.
812 function fetchTest(repositories, builders, filename, platform, metric, callback) {
813
814     function createRunAndResults(rawRuns) {
815         if (!rawRuns)
816             return null;
817
818         var runs = new PerfTestRuns(metric, platform);
819         var results = rawRuns.map(function (rawRun) {
820             // FIXME: Creating PerfTestResult and keeping them alive in memory all the time seems like a terrible idea.
821             // We should create PerfTestResult on demand.
822             return new PerfTestResult(runs, rawRun, new TestBuild(repositories, builders, platform, rawRun));
823         });
824         runs.setResults(results.sort(function (a, b) { return a.build().time() - b.build().time(); }));
825         return runs;
826     }
827
828     $.getJSON('api/runs/' + filename + '?cache=true', function (response) {
829         var data = response.configurations;
830         callback(createRunAndResults(data.current), createRunAndResults(data.baseline), createRunAndResults(data.target));
831     });
832 }
833
834 function fileNameFromPlatformAndTest(platformId, testId) {
835     return platformId + '-' + testId + '.json';
836 }
837
838 function init() {
839     var allPlatforms;
840     var tests = [];
841     var fullNameToMetric;
842     var dashboardPlatforms;
843     var runsCache = {}; // FIXME: We need to clear this cache at some point.
844     var repositories;
845     var builders;
846     var bugTrackers;
847
848     // FIXME: Show some error message when we get 404.
849     function getOrFetchTest(platform, metric, callback) {
850         var filename = fileNameFromPlatformAndTest(platform.id, metric.id);
851         var caches = runsCache[filename];
852
853         if (caches)
854             setTimeout(function () { callback(caches.current, caches.baseline, caches.target); }, 0);
855         else {
856             fetchTest(repositories, builders, filename, platform, metric, function (current, baseline, target) {
857                 runsCache[filename] = {current:current, baseline:baseline, target:target};
858                 callback(current, baseline, target);
859             });
860         }
861     }
862
863     function showDashboard() {
864         var dashboardTable = $('<table id="dashboard"></table>');
865         $('#mainContents').html(dashboardTable);
866
867         // Split dashboard platforms into groups of three so that it doesn't grow horizontally too much.
868         // FIXME: Make this adaptive.
869         for (var i = 0; i < Math.ceil(dashboardPlatforms.length / 3); i++)
870             addPlatformsToDashboard(dashboardTable, dashboardPlatforms.slice(i * 3, (i + 1) * 3));
871
872         URLState.remove('chartList');
873     }
874
875     function addPlatformsToDashboard(dashboardTable, selectedPlatforms) {
876         var header = document.createElement('thead');
877         selectedPlatforms.forEach(function (platform) {
878             var cell = document.createElement('th');
879             $(cell).text(platform.name);
880             header.appendChild(cell);
881         });
882         dashboardTable.append(header);
883
884         var tbody = $(document.createElement('tbody'));
885         dashboardTable.append(tbody);
886         var row = document.createElement('tr');
887         tbody.append(row);
888
889         selectedPlatforms.forEach(function (platform) {
890             var cell = document.createElement('td');
891             row.appendChild(cell);
892
893             platform.metrics.forEach(function (metric) {
894                 var chart = new Chart(cell, true, platform, metric, bugTrackers);
895                 getOrFetchTest(platform, metric, function (results, baseline, target) {
896                     // FIXME: We shouldn't rely on the order in which XHR finishes to order plots.
897                     if (dashboardTable.parent().length) {
898                         chart.populate(results, baseline, target);
899                         chart.attach();
900                     }
901                 });
902             });
903         });
904     }
905
906     function showCharts(lists) {
907         var chartsContainer = document.createElement('section');
908         chartsContainer.id = 'charts';
909         $('#mainContents').html(chartsContainer);
910
911         var testPicker = document.createElement('section');
912         testPicker.id = 'testPicker';
913
914         function addOption(select, label, value) {
915             var option = document.createElement('option');
916             option.appendChild(document.createTextNode(label));
917             if (value)
918                 option.value = value;
919             select.appendChild(option);
920         }
921
922         var testList = document.createElement('select');
923         testList.id = 'testList';
924         testPicker.appendChild(testList);
925         for (var i = 0; i < tests.length; ++i) {
926             if (tests[i].parentTest)
927                 continue;
928             addOption(testList, tests[i].fullName, tests[i].id);
929         }
930
931         var metricList = document.createElement('select');
932         metricList.id = 'metricList';
933         testPicker.appendChild(metricList);
934
935         var platformList = document.createElement('select');
936         platformList.id = 'platformList';
937         testPicker.appendChild(platformList);
938         const OPTION_VALUE_FOR_ALL = '-';
939         addOption(platformList, 'All platforms', OPTION_VALUE_FOR_ALL);
940         for (var i = 0; i < allPlatforms.length; ++i)
941             addOption(platformList, allPlatforms[i].name);
942
943         testList.onchange = function () {
944             while (metricList.firstChild)
945                 metricList.removeChild(metricList.firstChild);
946
947             var metricsGroup = document.createElement('optgroup');
948             metricsGroup.label = 'Metrics';
949             metricList.appendChild(metricsGroup);
950             addOption(metricsGroup, 'All metrics', OPTION_VALUE_FOR_ALL);
951             for (var i = 0; i < tests.length; ++i) {
952                 if (tests[i].id == testList.value) {
953                     var selectedTest = tests[i];
954                     for (var j = 0; j < selectedTest.metrics.length; ++j) {
955                         var fullName = selectedTest.metrics[j].fullName;
956                         var relativeName = fullName.replace(selectedTest.fullName, '').replace(/^[:/]/, '');
957                         addOption(metricsGroup, relativeName, fullName);
958                     }
959                 }
960             }
961             var subtestsGroup = document.createElement('optgroup');
962             subtestsGroup.label = 'Tests';
963             metricList.appendChild(subtestsGroup);
964             addOption(subtestsGroup, 'All subtests', OPTION_VALUE_FOR_ALL);
965             for (var i = 0; i < tests.length; ++i) {
966                 if (!tests[i].parentTest || tests[i].parentTest.id != testList.value)
967                     continue;
968                 var subtest = tests[i];
969                 var selectedTest = subtest.parentTest;
970                 for (var j = 0; j < subtest.metrics.length; ++j) {
971                     var fullName = subtest.metrics[j].fullName;
972                     var relativeName = fullName.replace(selectedTest.fullName, '').replace(/^[:/]/, '');
973                     addOption(subtestsGroup, relativeName, fullName);
974                 }
975             }
976         }
977         metricList.onchange = function () {
978             var metric = fullNameToMetric[metricList.value];
979             var shouldAddAllMetrics = metricList.value === OPTION_VALUE_FOR_ALL;
980             for (var i = 0; i < platformList.options.length; ++i) {
981                 var option = platformList.options[i];
982                 if (option.value === OPTION_VALUE_FOR_ALL) // Adding all metrics for all platforms will be too slow.
983                     option.disabled = shouldAddAllMetrics;
984                 else {
985                     var platform = nameToPlatform[option.value];
986                     var platformHasMetric = platform.metrics.indexOf(metric) >= 0;
987                     option.disabled = !shouldAddAllMetrics && !platformHasMetric;
988                 }
989             }
990         }
991         testList.onchange();
992         metricList.onchange();
993
994         $(testPicker).append(' <a href="">Add Chart</a>');
995
996         function removeChart(chart) {
997             for (var i = 0; i < chartList.length; i++) {
998                 if (chartList[i][0] == chart.platform().name && chartList[i][1] == chart.metric().fullName) {
999                     chartList.splice(i, 1);
1000                     break;
1001                 }
1002             }
1003             URLState.set('chartList', JSON.stringify(chartList));
1004         }
1005
1006         function createChartFromListPair(platformName, metricFullName) {
1007             var platform = nameToPlatform[platformName];
1008             var metric = fullNameToMetric[metricFullName]
1009             var chart = new Chart(chartsContainer, false, platform, metric, bugTrackers, removeChart);
1010
1011             getOrFetchTest(platform, metric, function (results, baseline, target) {
1012                 if (!chartsContainer.parentNode)
1013                     return;
1014                 chart.populate(results, baseline, target);
1015                 chart.attach();
1016             });
1017         }
1018
1019         $(testPicker).children('a').bind('click', function (event) {
1020             event.preventDefault();
1021
1022             var newChartList = [];
1023             if (platformList.value === OPTION_VALUE_FOR_ALL) {
1024                 for (var i = 0; i < allPlatforms.length; ++i) {
1025                     createChartFromListPair(allPlatforms[i].name, metricList.value);
1026                     newChartList.push([allPlatforms[i].name, metricList.value]);
1027                 }
1028             } else if (metricList.value === OPTION_VALUE_FOR_ALL) {
1029                 var group = metricList.selectedOptions[0].parentNode;
1030                 var metricsToAdd = [];
1031                 for (var i = 0; i < group.children.length; i++) {
1032                     var metric = group.children[i].value;
1033                     if (metric == OPTION_VALUE_FOR_ALL)
1034                         continue;
1035                     createChartFromListPair(platformList.value, metric);
1036                     newChartList.push([platformList.value, metric]);
1037                 }
1038             } else {
1039                 createChartFromListPair(platformList.value, metricList.value);
1040                 newChartList.push([platformList.value, metricList.value]);
1041             }
1042
1043             chartList = chartList.concat(newChartList);
1044             URLState.set('chartList', JSON.stringify(chartList));
1045
1046             return false;
1047         });
1048
1049         $('#mainContents').append(testPicker);
1050
1051         var chartList = [];
1052         try {
1053             chartList = JSON.parse(URLState.get('chartList', ''));
1054             // FIXME: Should we verify that platform and test names are valid here?
1055         } catch (exception) {
1056             // Ignore any exception thrown by parse.
1057         }
1058
1059         chartList.forEach(function (item) { createChartFromListPair(item[0], item[1]); });
1060     }
1061
1062     // FIXME: We should use exponential slider for charts page where we expect to have
1063     // the full JSON as opposed to the dashboard where we can't afford loading really big JSON files.
1064     var exponential = true;
1065     (function () {
1066         var input = $('#numberOfDays')[0];
1067         var updating = 0;
1068         function updateSpanAndCall(newNumberOfDays) {
1069             $('#numberOfDays').next().text(newNumberOfDays + ' days');
1070             // FIXME: This is likely broken for non-PST.
1071             Chart.setMinTime(Date.now() - newNumberOfDays * 24 * 3600 * 1000);
1072         }
1073         $('#numberOfDays').bind('change', function () {
1074             var newNumberOfDays = Math.round(exponential ? Math.exp(input.value) : input.value);
1075             URLState.remove('zoom');
1076             Chart.clearZoom();
1077             URLState.set('days', newNumberOfDays);
1078             updateSpanAndCall(newNumberOfDays);
1079         });
1080         function onchange() {
1081             var newNumberOfDays = URLState.get('days', Math.round(exponential ? Math.exp(input.defaultValue) : input.defaultValue));
1082             $('#numberOfDays').val(exponential ? Math.log(newNumberOfDays) : newNumberOfDays);
1083             updateSpanAndCall(newNumberOfDays);
1084         }
1085         URLState.watch('days', onchange);
1086         onchange();
1087     })();
1088
1089     URLState.watch('mode', function () { setMode(URLState.get('mode')); });
1090     URLState.watch('chartList', function (changedStates) {
1091         var modeChanged = changedStates.indexOf('mode') >= 0;
1092         if (URLState.get('mode') == 'charts' && !modeChanged)
1093             setMode('charts');
1094     });
1095
1096     function zoomChartsIfParsedCorrectly() {
1097         try {
1098             zoomValues = JSON.parse(URLState.get('zoom', '[]'));
1099             if (zoomValues.length != 2)
1100                 return false;
1101             Chart.zoom(parseFloat(zoomValues[0]), parseFloat(zoomValues[1]));
1102         } catch (exception) {
1103             // Ignore all exceptions thrown by JSON.parse.
1104         }
1105         return true;
1106     }
1107
1108     URLState.watch('zoom', function (changedStates) {
1109         var modeChanged = changedStates.indexOf('mode') >= 0;
1110         if (URLState.get('mode') == 'charts' && !modeChanged) {
1111             if (!zoomChartsIfParsedCorrectly())
1112                 return Chart.clearZoom();
1113         }
1114     });
1115
1116     Chart.onzoomchange = function (from, to) {
1117         if (from && to)
1118             URLState.set('zoom', JSON.stringify([from, to]));
1119         else
1120             URLState.remove('zoom');
1121     }
1122
1123     window.openChart = function (platform, metric) {
1124         URLState.set('chartList', JSON.stringify([[platform.name, metric.fullName]]));
1125         setMode('charts');
1126     }
1127
1128     window.setMode = function (newMode) {
1129         URLState.set('mode', newMode);
1130
1131         Chart.clear();
1132         if (newMode == 'dashboard') {
1133             URLState.remove('zoom');
1134             Chart.clearZoom();
1135             showDashboard();
1136         } else { // FIXME: Dynamically obtain the list of tests to show.
1137             showCharts();
1138             zoomChartsIfParsedCorrectly();
1139         }
1140     }
1141
1142     function fullName(test) {
1143         var names = [];
1144         do {
1145             names.push(test.name);
1146             test = test.parentTest;
1147         } while (test);
1148         names.reverse();
1149         return names.join('/');
1150     }
1151
1152     // Perhaps we want an ordered list of platforms.
1153     $.getJSON('data/manifest.json', function (data) {
1154         var manifest = data;
1155
1156         nameToTest = {};
1157         for (var testId in manifest.tests) {
1158             var test = manifest.tests[testId];
1159             test.parentTest = manifest.tests[manifest.tests[testId].parentId];
1160             test.id = testId;
1161             test.metrics = [];
1162             tests.push(test);
1163         }
1164         tests.forEach(function (test) {
1165             test.fullName = fullName(test);
1166             nameToTest[test.fullName] = test;
1167         });
1168         tests.sort(function (a, b) {
1169             if (a.fullName < b.fullName)
1170                 return -1;
1171             if (a.fullName > b.fullName)
1172                 return 1;
1173             return 0;
1174         });
1175
1176         fullNameToMetric = {};
1177         for (var metricId in manifest.metrics) {
1178             var entry = manifest.metrics[metricId];
1179             entry.id = metricId;
1180             entry.test = manifest.tests[entry.test];
1181             entry.fullName = entry.test.fullName + ':' + entry.name;
1182             if (entry.aggregator)
1183                 entry.fullName += ':' + entry.aggregator;
1184             entry.test.metrics.push(entry);
1185             fullNameToMetric[entry.fullName] = entry;
1186         }
1187
1188         tests.forEach(function (test) {
1189             test.metrics.sort(function (a, b) {
1190                 if (a.name < b.name)
1191                     return -1;
1192                 if (a.name > b.name)
1193                     return 1;
1194                 return 0;
1195             });
1196         });
1197
1198         allPlatforms = [];
1199         nameToPlatform = {};
1200         for (var platformId in manifest.all) {
1201             var platform = manifest.all[platformId];
1202             platform.id = platformId;
1203             allPlatforms.push(platform);
1204             nameToPlatform[platform.name] = platform;
1205             // FIXME: Sort tests
1206             for (var i = 0; i < platform.metrics.length; ++i)
1207                 platform.metrics[i] = manifest.metrics[platform.metrics[i]];
1208         }
1209         allPlatforms.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
1210
1211         dashboardPlatforms = [];
1212         for (var platformId in manifest.dashboard) {
1213             var platform = manifest.dashboard[platformId];
1214             platform.id = platformId;
1215             for (var i = 0; i < platform.metrics.length; ++i)
1216                 platform.metrics[i] = manifest.metrics[platform.metrics[i]];
1217             platform.metrics.sort(function (a, b) { return a.fullName < b.fullName ? -1 : (a.fullName > b.fullName ? 1 : 0); });
1218             dashboardPlatforms.push(manifest.dashboard[platformId]);
1219         }
1220         dashboardPlatforms.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
1221
1222         repositories = manifest.repositories;
1223         builders = manifest.builders;
1224         bugTrackers = manifest.bugTrackers;
1225
1226         document.title = manifest.siteTitle;
1227         document.getElementById('siteTitle').textContent = manifest.siteTitle;
1228
1229         setMode(URLState.get('mode', 'dashboard'));
1230     });
1231 }
1232
1233 window.addEventListener('DOMContentLoaded', init, false);
1234
1235 </script>
1236 </head>
1237 <body>
1238
1239 <header id="title">
1240 <h1><a id="siteTitle" href="/">Perf Monitor</a></h1>
1241 <ul>
1242     <li id="numberOfDaysPicker"><input id="numberOfDays" type="range" min="1" max="5.9" step="0.001" value="2.3"><span class="output"></span></li>
1243     <li><a href="javascript:setMode('dashboard');">Dashboard</a></li>
1244     <li><a href="javascript:setMode('charts');">Charts</a></li>
1245 </ul>
1246 </header>
1247
1248 <div id="mainContents"></div>
1249 </body>
1250 </html>