Perf dashboard always fetches charts JSON twice
[WebKit-https.git] / Websites / perf.webkit.org / public / v2 / app.js
1 window.App = Ember.Application.create();
2
3 App.Router.map(function () {
4     this.resource('customDashboard', {path: 'dashboard/custom'});
5     this.resource('dashboard', {path: 'dashboard/:name'});
6     this.resource('charts', {path: 'charts'});
7     this.resource('analysis', {path: 'analysis'});
8     this.resource('analysisTask', {path: 'analysis/task/:taskId'});
9 });
10
11 App.DashboardRow = Ember.Object.extend({
12     header: null,
13     cells: [],
14
15     init: function ()
16     {
17         this._super();
18
19         var cellsInfo = this.get('cellsInfo') || [];
20         var columnCount = this.get('columnCount');
21         while (cellsInfo.length < columnCount)
22             cellsInfo.push([]);
23
24         this.set('cells', cellsInfo.map(this._createPane.bind(this)));
25     },
26     addPane: function (paneInfo)
27     {
28         var pane = this._createPane(paneInfo);
29         this.get('cells').pushObject(pane);
30         this.set('columnCount', this.get('columnCount') + 1);
31     },
32     _createPane: function (paneInfo)
33     {
34         if (!paneInfo || !paneInfo.length || (!paneInfo[0] && !paneInfo[1]))
35             paneInfo = null;
36
37         var pane = App.Pane.create({
38             store: this.get('store'),
39             platformId: paneInfo ? paneInfo[0] : null,
40             metricId: paneInfo ? paneInfo[1] : null,
41             inDashboard: true
42         });
43
44         return App.DashboardPaneProxyForPicker.create({content: pane});
45     },
46 });
47
48 App.DashboardPaneProxyForPicker = Ember.ObjectProxy.extend({
49     _platformOrMetricIdChanged: function ()
50     {
51         var self = this;
52         App.buildPopup(this.get('store'), 'choosePane', this)
53             .then(function (platforms) { self.set('pickerData', platforms); });
54     }.observes('platformId', 'metricId').on('init'),
55     paneList: function () {
56         return App.encodePrettifiedJSON([[this.get('platformId'), this.get('metricId'), null, null, null, null, null]]);
57     }.property('platformId', 'metricId'),
58 });
59
60 App.IndexRoute = Ember.Route.extend({
61     beforeModel: function ()
62     {
63         var self = this;
64         App.Manifest.fetch(this.store).then(function () {
65             self.transitionTo('dashboard', App.Manifest.defaultDashboardName() || '');
66         });
67     },
68 });
69
70 App.DashboardRoute = Ember.Route.extend({
71     model: function (param)
72     {
73         return App.Manifest.fetch(this.store).then(function () {
74             return App.Manifest.dashboardByName(param.name);
75         });
76     },
77 });
78
79 App.CustomDashboardRoute = Ember.Route.extend({
80     controllerName: 'dashboard',
81     model: function (param)
82     {
83         return this.store.createRecord('dashboard', {serialized: param.grid});
84     },
85     renderTemplate: function()
86     {
87         this.render('dashboard');
88     }
89 });
90
91 App.DashboardController = Ember.Controller.extend({
92     queryParams: ['grid', 'numberOfDays'],
93     headerColumns: [],
94     rows: [],
95     numberOfDays: 7,
96     editMode: false,
97
98     modelChanged: function ()
99     {
100         var dashboard = this.get('model');
101         if (!dashboard)
102             return;
103
104         var headerColumns = dashboard.get('headerColumns');
105         this.set('headerColumns', headerColumns);
106         var columnCount = headerColumns.length;
107         this.set('columnCount', columnCount);
108
109         var store = this.store;
110         this.set('rows', dashboard.get('rows').map(function (rowParam) {
111             return App.DashboardRow.create({
112                 store: store,
113                 header: rowParam[0],
114                 cellsInfo: rowParam.slice(1),
115                 columnCount: columnCount,
116             })
117         }));
118
119         this.set('emptyRow', new Array(columnCount));
120     }.observes('model').on('init'),
121
122     computeGrid: function()
123     {
124         var headers = this.get('headerColumns').map(function (header) { return header.label; });
125         var table = [headers].concat(this.get('rows').map(function (row) {
126             return [row.get('header')].concat(row.get('cells').map(function (pane) {
127                 var platformAndMetric = [pane.get('platformId'), pane.get('metricId')];
128                 return platformAndMetric[0] || platformAndMetric[1] ? platformAndMetric : [];
129             }));
130         }));
131         return JSON.stringify(table);
132     },
133
134     _sharedDomainChanged: function ()
135     {
136         var numberOfDays = this.get('numberOfDays');
137         if (!numberOfDays)
138             return;
139
140         numberOfDays = parseInt(numberOfDays);
141         var present = Date.now();
142         var past = present - numberOfDays * 24 * 3600 * 1000;
143         this.set('since', past);
144         this.set('sharedDomain', [past, present]);
145     }.observes('numberOfDays').on('init'),
146
147     actions: {
148         setNumberOfDays: function (numberOfDays)
149         {
150             this.set('numberOfDays', numberOfDays);
151         },
152         choosePane: function (param)
153         {
154             var pane = param.position;
155             pane.set('platformId', param.platform.get('id'));
156             pane.set('metricId', param.metric.get('id'));
157         },
158         addColumn: function ()
159         {
160             this.get('headerColumns').pushObject({
161                 label: this.get('newColumnHeader'),
162                 index: this.get('headerColumns').length,
163             });
164             this.get('rows').forEach(function (row) {
165                 row.addPane();
166             });
167             this.set('newColumnHeader', null);
168         },
169         removeColumn: function (index)
170         {
171             this.get('headerColumns').removeAt(index);
172             this.get('rows').forEach(function (row) {
173                 row.get('cells').removeAt(index);
174             });
175         },
176         addRow: function ()
177         {
178             this.get('rows').pushObject(App.DashboardRow.create({
179                 store: this.store,
180                 header: this.get('newRowHeader'),
181                 columnCount: this.get('columnCount'),
182             }));
183             this.set('newRowHeader', null);
184         },
185         removeRow: function (row)
186         {
187             this.get('rows').removeObject(row);
188         },
189         resetPane: function (pane)
190         {
191             pane.set('platformId', null);
192             pane.set('metricId', null);
193         },
194         toggleEditMode: function ()
195         {
196             this.toggleProperty('editMode');
197             if (this.get('editMode'))
198                 this.transitionToRoute('dashboard', 'custom', {name: null, queryParams: {grid: this.computeGrid()}});
199             else
200                 this.set('grid', this.computeGrid());
201         },
202     },
203
204     init: function ()
205     {
206         this._super();
207         App.Manifest.fetch(this.get('store'));
208     }
209 });
210
211 App.NumberOfDaysControlView = Ember.View.extend({
212     classNames: ['controls'],
213     templateName: 'number-of-days-controls',
214     didInsertElement: function ()
215     {
216         this._matchingElements(this._previousNumberOfDaysClass).addClass('active');
217     },
218     _numberOfDaysChanged: function ()
219     {
220         this._matchingElements(this._previousNumberOfDaysClass).removeClass('active');
221
222         var newNumberOfDaysClass = 'numberOfDaysIs' + this.get('numberOfDays');
223         this._matchingElements(this._previousNumberOfDaysClass).addClass('active');
224         this._previousNumberOfDaysClass = newNumberOfDaysClass;
225     }.observes('numberOfDays').on('init'),
226     _matchingElements: function (className)
227     {
228         var element = this.get('element');
229         if (!element)
230             return $([]);
231         return $(element.getElementsByClassName(className));
232     }
233 });
234
235 App.StartTimeSliderView = Ember.View.extend({
236     templateName: 'start-time-slider',
237     classNames: ['start-time-slider'],
238     startTime: Date.now() - 7 * 24 * 3600 * 1000,
239     oldestStartTime: null,
240     _numberOfDaysView: null,
241     _slider: null,
242     _startTimeInSlider: null,
243     _currentNumberOfDays: null,
244     _MILLISECONDS_PER_DAY: 24 * 3600 * 1000,
245
246     didInsertElement: function ()
247     {
248         this.oldestStartTime = Date.now() - 365 * 24 * 3600 * 1000;
249         this._slider = $(this.get('element')).find('input');
250         this._numberOfDaysView = $(this.get('element')).find('.numberOfDays');
251         this._sliderRangeChanged();
252         this._slider.change(this._sliderMoved.bind(this));
253     },
254     _sliderRangeChanged: function ()
255     {
256         var minimumNumberOfDays = 1;
257         var maximumNumberOfDays = this._timeInPastToNumberOfDays(this.get('oldestStartTime'));
258         var precision = 1000; // FIXME: Compute this from maximumNumberOfDays.
259         var slider = this._slider;
260         slider.attr('min', Math.floor(Math.log(Math.max(1, minimumNumberOfDays)) * precision) / precision);
261         slider.attr('max', Math.ceil(Math.log(maximumNumberOfDays) * precision) / precision);
262         slider.attr('step', 1 / precision);
263         this._startTimeChanged();
264     }.observes('oldestStartTime'),
265     _sliderMoved: function ()
266     {
267         this._currentNumberOfDays = Math.round(Math.exp(this._slider.val()));
268         this._numberOfDaysView.text(this._currentNumberOfDays);
269         this._startTimeInSlider = this._numberOfDaysToTimeInPast(this._currentNumberOfDays);
270         this.set('startTime', this._startTimeInSlider);
271     },
272     _startTimeChanged: function ()
273     {
274         var startTime = this.get('startTime');
275         if (startTime == this._startTimeSetBySlider)
276             return;
277         this._currentNumberOfDays = this._timeInPastToNumberOfDays(startTime);
278
279         if (this._slider) {
280             this._numberOfDaysView.text(this._currentNumberOfDays);
281             this._slider.val(Math.log(this._currentNumberOfDays));
282             this._startTimeInSlider = startTime;
283         }
284     }.observes('startTime').on('init'),
285     _timeInPastToNumberOfDays: function (timeInPast)
286     {
287         return Math.max(1, Math.round((Date.now() - timeInPast) / this._MILLISECONDS_PER_DAY));
288     },
289     _numberOfDaysToTimeInPast: function (numberOfDays)
290     {
291         return Date.now() - numberOfDays * this._MILLISECONDS_PER_DAY;
292     },
293 });
294
295 App.Pane = Ember.Object.extend({
296     platformId: null,
297     platform: null,
298     metricId: null,
299     metric: null,
300     selectedItem: null,
301     selectedPoints: null,
302     hoveredOrSelectedItem: null,
303     showFullYAxis: false,
304     inDashboard: false,
305     searchCommit: function (repository, keyword) {
306         var self = this;
307         var repositoryId = repository.get('id');
308         CommitLogs.fetchCommits(repositoryId, null, null, keyword).then(function (commits) {
309             if (self.isDestroyed || !self.get('chartData') || !commits.length)
310                 return;
311             var currentRuns = self.get('chartData').current.series();
312             if (!currentRuns.length)
313                 return;
314
315             var highlightedItems = {};
316             var commitIndex = 0;
317             for (var runIndex = 0; runIndex < currentRuns.length && commitIndex < commits.length; runIndex++) {
318                 var measurement = currentRuns[runIndex].measurement;
319                 var commitTime = measurement.commitTimeForRepository(repositoryId);
320                 if (!commitTime)
321                     continue;
322                 if (commits[commitIndex].time <= commitTime) {
323                     highlightedItems[measurement.id()] = true;
324                     do {
325                         commitIndex++;
326                     } while (commitIndex < commits.length && commits[commitIndex].time <= commitTime);
327                 }
328             }
329
330             self.set('highlightedItems', highlightedItems);
331         }, function () {
332             // FIXME: Report errors
333             this.set('highlightedItems', {});
334         });
335     },
336     _fetch: function () {
337         var platformId = this.get('platformId');
338         var metricId = this.get('metricId');
339         if (!platformId && !metricId) {
340             this.set('empty', true);
341             return;
342         }
343         this.set('empty', false);
344         this.set('platform', null);
345         this.set('chartData', null);
346         this.set('metric', null);
347         this.set('failure', null);
348
349         if (!this._isValidId(platformId))
350             this.set('failure', platformId ? 'Invalid platform id:' + platformId : 'Platform id was not specified');
351         else if (!this._isValidId(metricId))
352             this.set('failure', metricId ? 'Invalid metric id:' + metricId : 'Metric id was not specified');
353         else {
354             var self = this;
355             var useCache = true;
356             App.Manifest.fetchRunsWithPlatformAndMetric(this.get('store'), platformId, metricId, null, useCache)
357                 .then(function (result) {
358                     if (!result || !result.data || result.shouldRefetch)
359                         self.refetchRuns(platformId, metricId);
360                     else
361                         self._didFetchRuns(result);
362                 }, function () {
363                     self.refetchRuns(platformId, metricId);
364                 });
365             if (!this.get('inDashboard'))
366                 this.fetchAnalyticRanges();
367         }
368     }.observes('platformId', 'metricId').on('init'),
369     refetchRuns: function (platformId, metricId) {
370         if (!platformId)
371             platformId = this.get('platform').get('id');
372         if (!metricId)
373             metricId = this.get('metric').get('id');
374         Ember.assert('refetchRuns should be called only after platform and metric are resolved', platformId && metricId);
375
376         var useCache = false;
377         App.Manifest.fetchRunsWithPlatformAndMetric(this.get('store'), platformId, metricId, null, useCache)
378             .then(this._didFetchRuns.bind(this), this._handleFetchErrors.bind(this, platformId, metricId));
379     },
380     _didFetchRuns: function (result)
381     {
382         this.set('platform', result.platform);
383         this.set('metric', result.metric);
384         this._setNewChartData(result.data);
385     },
386     _showOutlierChanged: function ()
387     {
388         var chartData = this.get('chartData');
389         if (chartData)
390             this._setNewChartData(chartData);
391     }.observes('showOutlier'),
392     _setNewChartData: function (chartData)
393     {
394         var newChartData = {};
395         for (var property in chartData)
396             newChartData[property] = chartData[property];
397
398         var showOutlier = this.get('showOutlier');
399         newChartData.showOutlier(showOutlier);
400         this.set('chartData', newChartData);
401         this._updateMovingAverageAndEnvelope();
402
403         if (!this.get('anomalyDetectionStrategies').filterBy('enabled').length)
404             this._highlightPointsMarkedAsOutlier(newChartData);
405     },
406     _highlightPointsMarkedAsOutlier: function (newChartData)
407     {
408         var data = newChartData.current.series();
409         var items = {};
410         for (var i = 0; i < data.length; i++) {
411             if (data[i].measurement.markedOutlier())
412                 items[data[i].measurement.id()] = true;
413         }
414
415         this.set('highlightedItems', items);
416     },
417     _handleFetchErrors: function (platformId, metricId, result)
418     {
419         if (!result || typeof(result) === "string")
420             this.set('failure', 'Failed to fetch the JSON with an error: ' + result);
421         else if (!result.platform)
422             this.set('failure', 'Could not find the platform "' + platformId + '"');
423         else if (!result.metric)
424             this.set('failure', 'Could not find the metric "' + metricId + '"');
425         else
426             this.set('failure', 'An internal error');
427     },
428     fetchAnalyticRanges: function ()
429     {
430         var platformId = this.get('platformId');
431         var metricId = this.get('metricId');
432         var self = this;
433         this.get('store')
434             .findAll('analysisTask') // FIXME: Fetch only analysis tasks relevant for this pane.
435             .then(function (tasks) {
436                 self.set('analyticRanges', tasks.filter(function (task) {
437                     return task.get('platform').get('id') == platformId
438                         && task.get('metric').get('id') == metricId
439                         && task.get('startRun') && task.get('endRun');
440                 }));
441             });
442     },
443     ranges: function ()
444     {
445         var chartData = this.get('chartData');
446         if (!chartData || !chartData.unfilteredCurrentTimeSeries)
447             return [];
448
449         function midPoint(firstPoint, secondPoint) {
450             if (firstPoint && secondPoint)
451                 return (+firstPoint.time + +secondPoint.time) / 2;
452             if (firstPoint)
453                 return firstPoint.time;
454             return secondPoint.time;
455         }
456
457         var timeSeries = chartData.unfilteredCurrentTimeSeries;
458         var ranges = this.getWithDefault('analyticRanges', []);
459         var testranges = this.getWithDefault('testRangeCandidates', []);
460         return this.getWithDefault('analyticRanges', []).concat(this.getWithDefault('testRangeCandidates', [])).map(function (range) {
461             var start = timeSeries.findPointByMeasurementId(range.get('startRun'));
462             var end = timeSeries.findPointByMeasurementId(range.get('endRun'));
463
464             return Ember.ObjectProxy.create({
465                 content: range,
466                 startTime: start ? midPoint(timeSeries.previousPoint(start), start) : null,
467                 endTime: end ? midPoint(end, timeSeries.nextPoint(end)) : null,
468             });
469         });
470     }.property('chartData', 'analyticRanges', 'testRangeCandidates'),
471     _isValidId: function (id)
472     {
473         if (typeof(id) == "number")
474             return true;
475         if (typeof(id) == "string")
476             return !!id.match(/^[A-Za-z0-9_]+$/);
477         return false;
478     },
479     computeStatus: function (currentPoint, previousPoint)
480     {
481         var chartData = this.get('chartData');
482         var diffFromBaseline = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.baseline);
483         var diffFromTarget = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.target);
484
485         var label = '';
486         var className = '';
487         var formatter = d3.format('.3p');
488
489         var smallerIsBetter = chartData.smallerIsBetter;
490         if (diffFromBaseline !== undefined && diffFromBaseline > 0 == smallerIsBetter) {
491             label = formatter(Math.abs(diffFromBaseline)) + ' ' + (smallerIsBetter ? 'above' : 'below') + ' baseline';
492             className = 'worse';
493         } else if (diffFromTarget !== undefined && diffFromTarget < 0 == smallerIsBetter) {
494             label = formatter(Math.abs(diffFromTarget)) + ' ' + (smallerIsBetter ? 'below' : 'above') + ' target';
495             className = 'better';
496         } else if (diffFromTarget !== undefined)
497             label = formatter(Math.abs(diffFromTarget)) + ' until target';
498
499         var valueDelta = null;
500         var relativeDelta = null;
501         if (previousPoint) {
502             valueDelta = chartData.deltaFormatter(currentPoint.value - previousPoint.value);
503             relativeDelta = d3.format('+.2p')((currentPoint.value - previousPoint.value) / previousPoint.value);
504         }
505         return {
506             className: className,
507             label: label,
508             currentValue: chartData.formatter(currentPoint.value),
509             valueDelta: valueDelta,
510             relativeDelta: relativeDelta,
511         };
512     },
513     _relativeDifferentToLaterPointInTimeSeries: function (currentPoint, timeSeries)
514     {
515         if (!currentPoint || !timeSeries)
516             return undefined;
517
518         var referencePoint = timeSeries.findPointAfterTime(currentPoint.time);
519         if (!referencePoint)
520             return undefined;
521
522         return (currentPoint.value - referencePoint.value) / referencePoint.value;
523     },
524     latestStatus: function ()
525     {
526         var chartData = this.get('chartData');
527         if (!chartData || !chartData.current)
528             return null;
529
530         var lastPoint = chartData.current.lastPoint();
531         if (!lastPoint)
532             return null;
533
534         return this.computeStatus(lastPoint, chartData.current.previousPoint(lastPoint));
535     }.property('chartData'),
536     updateStatisticsTools: function ()
537     {
538         var movingAverageStrategies = Statistics.MovingAverageStrategies.map(this._cloneStrategy.bind(this));
539         this.set('movingAverageStrategies', [{label: 'None'}].concat(movingAverageStrategies));
540         this.set('chosenMovingAverageStrategy', this._configureStrategy(movingAverageStrategies, this.get('movingAverageConfig')));
541
542         var envelopingStrategies = Statistics.EnvelopingStrategies.map(this._cloneStrategy.bind(this));
543         this.set('envelopingStrategies', [{label: 'None'}].concat(envelopingStrategies));
544         this.set('chosenEnvelopingStrategy', this._configureStrategy(envelopingStrategies, this.get('envelopingConfig')));
545
546         var testRangeSelectionStrategies = Statistics.TestRangeSelectionStrategies.map(this._cloneStrategy.bind(this));
547         this.set('testRangeSelectionStrategies', [{label: 'None'}].concat(testRangeSelectionStrategies));
548         this.set('chosenTestRangeSelectionStrategy', this._configureStrategy(testRangeSelectionStrategies, this.get('testRangeSelectionConfig')));
549
550         var anomalyDetectionStrategies = Statistics.AnomalyDetectionStrategy.map(this._cloneStrategy.bind(this));
551         this.set('anomalyDetectionStrategies', anomalyDetectionStrategies);
552     }.on('init'),
553     _cloneStrategy: function (strategy)
554     {
555         var parameterList = (strategy.parameterList || []).map(function (param) { return Ember.Object.create(param); });
556         return Ember.Object.create({
557             id: strategy.id,
558             label: strategy.label,
559             isSegmentation: strategy.isSegmentation,
560             description: strategy.description,
561             parameterList: parameterList,
562             execute: strategy.execute,
563         });
564     },
565     _configureStrategy: function (strategies, config)
566     {
567         if (!config || !config[0])
568             return null;
569
570         var id = config[0];
571         var chosenStrategy = strategies.find(function (strategy) { return strategy.id == id });
572         if (!chosenStrategy)
573             return null;
574
575         if (chosenStrategy.parameterList) {
576             for (var i = 0; i < chosenStrategy.parameterList.length; i++)
577                 chosenStrategy.parameterList[i].value = parseFloat(config[i + 1]);
578         }
579
580         return chosenStrategy;
581     },
582     _updateMovingAverageAndEnvelope: function ()
583     {
584         var chartData = this.get('chartData');
585         if (!chartData)
586             return;
587
588         var movingAverageStrategy = this.get('chosenMovingAverageStrategy');
589         this._updateStrategyConfigIfNeeded(movingAverageStrategy, 'movingAverageConfig');
590
591         var envelopingStrategy = this.get('chosenEnvelopingStrategy');
592         this._updateStrategyConfigIfNeeded(envelopingStrategy, 'envelopingConfig');
593
594         var testRangeSelectionStrategy = this.get('chosenTestRangeSelectionStrategy');
595         this._updateStrategyConfigIfNeeded(testRangeSelectionStrategy, 'testRangeSelectionConfig');
596
597         var anomalyDetectionStrategies = this.get('anomalyDetectionStrategies').filterBy('enabled');
598         var result = this._computeMovingAverageAndOutliers(chartData, movingAverageStrategy, envelopingStrategy, testRangeSelectionStrategy, anomalyDetectionStrategies);
599         if (!result)
600             return;
601
602         chartData.movingAverage = result.movingAverage;
603         this.set('highlightedItems', result.anomalies);
604         var currentTimeSeriesData = chartData.current.series();
605         this.set('testRangeCandidates', result.testRangeCandidates.map(function (range) {
606             return Ember.Object.create({
607                 startRun: currentTimeSeriesData[range[0]].measurement.id(),
608                 endRun: currentTimeSeriesData[range[1]].measurement.id(),
609                 status: 'testingRange',
610             });
611         }));
612     },
613     _movingAverageOrEnvelopeStrategyDidChange: function () {
614         var chartData = this.get('chartData');
615         if (!chartData)
616             return;
617         this._setNewChartData(chartData);
618     }.observes('chosenMovingAverageStrategy', 'chosenMovingAverageStrategy.parameterList.@each.value',
619         'chosenEnvelopingStrategy', 'chosenEnvelopingStrategy.parameterList.@each.value',
620         'chosenTestRangeSelectionStrategy', 'chosenTestRangeSelectionStrategy.parameterList.@each.value',
621         'anomalyDetectionStrategies.@each.enabled'),
622     _computeMovingAverageAndOutliers: function (chartData, movingAverageStrategy, envelopingStrategy, testRangeSelectionStrategy, anomalyDetectionStrategies)
623     {
624         var currentTimeSeriesData = chartData.current.series();
625
626         var rawValues = chartData.current.rawValues();
627         var movingAverageIsSetByUser = movingAverageStrategy && movingAverageStrategy.execute;
628         if (!movingAverageIsSetByUser)
629             return null;
630
631         var movingAverageValues = Statistics.executeStrategy(movingAverageStrategy, rawValues);
632         if (!movingAverageValues)
633             return null;
634
635         var testRangeCandidates = [];
636         if (movingAverageStrategy && movingAverageStrategy.isSegmentation && testRangeSelectionStrategy && testRangeSelectionStrategy.execute)
637             testRangeCandidates = Statistics.executeStrategy(testRangeSelectionStrategy, rawValues, [movingAverageValues]);
638
639         if (envelopingStrategy && envelopingStrategy.execute) {
640             var envelopeDelta = Statistics.executeStrategy(envelopingStrategy, rawValues, [movingAverageValues]);
641             var anomalies = {};
642             if (anomalyDetectionStrategies.length) {
643                 var isAnomalyArray = new Array(currentTimeSeriesData.length);
644                 for (var strategy of anomalyDetectionStrategies) {
645                     var anomalyLengths = Statistics.executeStrategy(strategy, rawValues, [movingAverageValues, envelopeDelta]);
646                     for (var i = 0; i < currentTimeSeriesData.length; i++)
647                         isAnomalyArray[i] = isAnomalyArray[i] || anomalyLengths[i];
648                 }
649                 for (var i = 0; i < isAnomalyArray.length; i++) {
650                     if (!isAnomalyArray[i])
651                         continue;
652                     anomalies[currentTimeSeriesData[i].measurement.id()] = true;
653                     while (isAnomalyArray[i] && i < isAnomalyArray.length)
654                         ++i;
655                 }
656             }
657         }
658
659         var movingAverageTimeSeries = null;
660         if (movingAverageIsSetByUser) {
661             movingAverageTimeSeries = new TimeSeries(currentTimeSeriesData.map(function (point, index) {
662                 var value = movingAverageValues[index];
663                 return {
664                     measurement: point.measurement,
665                     time: point.time,
666                     value: value,
667                     interval: envelopeDelta !== null ? [value - envelopeDelta, value + envelopeDelta] : null,
668                 }
669             }));
670         }
671
672         return {
673             movingAverage: movingAverageTimeSeries,
674             anomalies: anomalies,
675             testRangeCandidates: testRangeCandidates,
676         };
677     },
678     _updateStrategyConfigIfNeeded: function (strategy, configName)
679     {
680         var config = null;
681         if (strategy && strategy.execute)
682             config = [strategy.id].concat((strategy.parameterList || []).map(function (param) { return param.value; }));
683
684         if (JSON.stringify(config) != JSON.stringify(this.get(configName)))
685             this.set(configName, config);
686     },
687     _updateDetails: function ()
688     {
689         var selectedPoints = this.get('selectedPoints');
690         var currentPoint = this.get('hoveredOrSelectedItem');
691         if (!selectedPoints && !currentPoint) {
692             this.set('details', null);
693             return;
694         }
695
696         var currentMeasurement;
697         var previousPoint;
698         if (!selectedPoints)
699             previousPoint = currentPoint.series.previousPoint(currentPoint);
700         else {
701             currentPoint = selectedPoints[selectedPoints.length - 1];
702             previousPoint = selectedPoints[0];
703         }
704         var currentMeasurement = currentPoint.measurement;
705         var oldMeasurement = previousPoint ? previousPoint.measurement : null;
706
707         var formattedRevisions = currentMeasurement.formattedRevisions(oldMeasurement);
708         var revisions = App.Manifest.get('repositories')
709             .filter(function (repository) { return formattedRevisions[repository.get('id')]; })
710             .map(function (repository) {
711             var revision = Ember.Object.create(formattedRevisions[repository.get('id')]);
712             revision['url'] = revision.previousRevision
713                 ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
714                 : repository.urlForRevision(revision.currentRevision);
715             revision['name'] = repository.get('name');
716             revision['repository'] = repository;
717             return revision; 
718         });
719
720         var buildNumber = null;
721         var buildURL = null;
722         if (!selectedPoints) {
723             buildNumber = currentMeasurement.buildNumber();
724             var builder = App.Manifest.builder(currentMeasurement.builderId());
725             if (builder)
726                 buildURL = builder.urlFromBuildNumber(buildNumber);
727         }
728
729         this.set('details', Ember.Object.create({
730             status: this.computeStatus(currentPoint, previousPoint),
731             buildNumber: buildNumber,
732             buildURL: buildURL,
733             buildTime: currentMeasurement.formattedBuildTime(),
734             revisions: revisions,
735         }));
736     }.observes('hoveredOrSelectedItem', 'selectedPoints'),
737 });
738
739 App.encodePrettifiedJSON = function (plain)
740 {
741     function numberIfPossible(string) {
742         return string == parseInt(string) ? parseInt(string) : string;
743     }
744
745     function recursivelyConvertNumberIfPossible(input) {
746         if (input instanceof Array) {
747             return input.map(recursivelyConvertNumberIfPossible);
748         }
749         return numberIfPossible(input);
750     }
751
752     return JSON.stringify(recursivelyConvertNumberIfPossible(plain))
753         .replace(/\[/g, '(').replace(/\]/g, ')').replace(/\,/g, '-');
754 }
755
756 App.decodePrettifiedJSON = function (encoded)
757 {
758     var parsed = encoded.replace(/\(/g, '[').replace(/\)/g, ']').replace(/\-/g, ',');
759     try {
760         return JSON.parse(parsed);
761     } catch (exception) {
762         return null;
763     }
764 }
765
766 App.ChartsController = Ember.Controller.extend({
767     queryParams: ['paneList', 'zoom', 'since'],
768     needs: ['pane'],
769     _currentEncodedPaneList: null,
770     panes: [],
771     platforms: null,
772     sharedZoom: null,
773     startTime: null,
774     present: Date.now(),
775     defaultSince: Date.now() - 7 * 24 * 3600 * 1000,
776
777     addPane: function (pane)
778     {
779         this.panes.unshiftObject(pane);
780     },
781
782     removePane: function (pane)
783     {
784         this.panes.removeObject(pane);
785     },
786
787     refreshPanes: function()
788     {
789         var paneList = this.get('paneList');
790         if (paneList === this._currentEncodedPaneList)
791             return;
792
793         var panes = this._parsePaneList(paneList || '[]');
794         if (!panes) {
795             console.log('Failed to parse', jsonPaneList, exception);
796             return;
797         }
798         this.set('panes', panes);
799         this._currentEncodedPaneList = paneList;
800     }.observes('paneList').on('init'),
801
802     refreshZoom: function()
803     {
804         var zoom = this.get('zoom');
805         if (!zoom) {
806             this.set('sharedZoom', null);
807             return;
808         }
809
810         zoom = zoom.split('-');
811         var selection = new Array(2);
812         try {
813             selection[0] = new Date(parseFloat(zoom[0]));
814             selection[1] = new Date(parseFloat(zoom[1]));
815         } catch (error) {
816             console.log('Failed to parse the zoom', zoom);
817         }
818         this.set('sharedZoom', selection);
819
820         var startTime = this.get('startTime');
821         if (startTime && startTime > selection[0])
822             this.set('startTime', selection[0]);
823
824     }.observes('zoom').on('init'),
825
826     _startTimeChanged: function () {
827         this.set('sharedDomain', [this.get('startTime'), this.get('present')]);
828         this._scheduleQueryStringUpdate();
829     }.observes('startTime'),
830
831     _sinceChanged: function () {
832         var since = parseInt(this.get('since'));
833         if (isNaN(since))
834             since = this.defaultSince;
835         this.set('startTime', new Date(since));
836     }.observes('since').on('init'),
837
838     _parsePaneList: function (encodedPaneList)
839     {
840         var parsedPaneList = App.decodePrettifiedJSON(encodedPaneList);
841         if (!parsedPaneList)
842             return null;
843
844         // FIXME: Don't re-create all panes.
845         var self = this;
846         return parsedPaneList.map(function (paneInfo) {
847             var timeRange = null;
848             var selectedItem = null;
849             if (paneInfo[2] instanceof Array) {
850                 var timeRange = paneInfo[2];
851                 try {
852                     timeRange = [new Date(timeRange[0]), new Date(timeRange[1])];
853                 } catch (error) {
854                     console.log("Failed to parse the time range:", timeRange, error);
855                 }
856             } else
857                 selectedItem = paneInfo[2];
858
859             return App.Pane.create({
860                 store: self.store,
861                 info: paneInfo,
862                 platformId: paneInfo[0],
863                 metricId: paneInfo[1],
864                 selectedItem: selectedItem,
865                 timeRange: timeRange,
866                 showFullYAxis: paneInfo[3],
867                 movingAverageConfig: paneInfo[4],
868                 envelopingConfig: paneInfo[5],
869                 testRangeSelectionConfig: paneInfo[6],
870             });
871         });
872     },
873
874     _serializePaneList: function (panes)
875     {
876         if (!panes.length)
877             return undefined;
878         var self = this;
879         return App.encodePrettifiedJSON(panes.map(function (pane) {
880             return [
881                 pane.get('platformId'),
882                 pane.get('metricId'),
883                 pane.get('timeRange') ? pane.get('timeRange').map(function (date) { return date.getTime() }) : pane.get('selectedItem'),
884                 pane.get('showFullYAxis'),
885                 pane.get('movingAverageConfig'),
886                 pane.get('envelopingConfig'),
887                 pane.get('testRangeSelectionConfig'),
888             ];
889         }));
890     },
891
892     _scheduleQueryStringUpdate: function ()
893     {
894         Ember.run.debounce(this, '_updateQueryString', 1000);
895     }.observes('sharedZoom', 'panes.@each.platform', 'panes.@each.metric', 'panes.@each.selectedItem', 'panes.@each.timeRange',
896         'panes.@each.showFullYAxis', 'panes.@each.movingAverageConfig', 'panes.@each.envelopingConfig', 'panes.@each.testRangeSelectionConfig'),
897
898     _updateQueryString: function ()
899     {
900         this._currentEncodedPaneList = this._serializePaneList(this.get('panes'));
901         this.set('paneList', this._currentEncodedPaneList);
902
903         var zoom = undefined;
904         var sharedZoom = this.get('sharedZoom');
905         if (sharedZoom && !App.domainsAreEqual(sharedZoom, this.get('sharedDomain')))
906             zoom = +sharedZoom[0] + '-' + +sharedZoom[1];
907         this.set('zoom', zoom);
908
909         if (this.get('startTime') - this.defaultSince)
910             this.set('since', this.get('startTime') - 0);
911     },
912
913     actions: {
914         addPaneByMetricAndPlatform: function (param)
915         {
916             this.addPane(App.Pane.create({
917                 store: this.store,
918                 platformId: param.platform.get('id'),
919                 metricId: param.metric.get('id'),
920                 showingDetails: false
921             }));
922         },
923         addAlternativePanes: function (pane, platform, metrics)
924         {
925             var panes = this.panes;
926             var store = this.store;
927             var startingIndex = panes.indexOf(pane) + 1;
928             metrics.forEach(function (metric, index) {
929                 panes.insertAt(startingIndex + index, App.Pane.create({
930                     store: store,
931                     platformId: platform.get('id'),
932                     metricId: metric.get('id'),
933                     showingDetails: false
934                 }));
935             })
936         }
937     },
938
939     init: function ()
940     {
941         this._super();
942         var self = this;
943         App.buildPopup(this.store, 'addPaneByMetricAndPlatform').then(function (platforms) {
944             self.set('platforms', platforms);
945         });
946     },
947 });
948
949 App.buildPopup = function(store, action, position)
950 {
951     return App.Manifest.fetch(store).then(function () {
952         return App.Manifest.get('platforms').sortBy('label').map(function (platform) {
953             return App.PlatformProxyForPopup.create({content: platform,
954                 action: action, position: position});
955         });
956     });
957 }
958
959 App.PlatformProxyForPopup = Ember.ObjectProxy.extend({
960     children: function ()
961     {
962         var platform = this.content;
963         var containsTest = this.content.containsTest.bind(this.content);
964         var action = this.get('action');
965         var position = this.get('position');
966         return App.Manifest.get('topLevelTests')
967             .filter(containsTest)
968             .map(function (test) {
969                 return App.TestProxyForPopup.create({content: test, platform: platform, action: action, position: position});
970             });
971     }.property('App.Manifest.topLevelTests'),
972 });
973
974 App.TestProxyForPopup = Ember.ObjectProxy.extend({
975     platform: null,
976     children: function ()
977     {
978         this._updateChildren();
979         return this._children;
980     }.property('childTests', 'metrics'),
981     actionName: function ()
982     {
983         this._updateChildren();
984         return this._actionName;
985     }.property('childTests', 'metrics'),
986     actionArgument: function ()
987     {
988         this._updateChildren();
989         return this._actionArgument;
990     }.property('childTests', 'metrics'),
991     _updateChildren: function ()
992     {
993         var platform = this.get('platform');
994         var action = this.get('action');
995         var position = this.get('position');
996
997         var childTests = this.get('childTests')
998             .filter(function (test) { return platform.containsTest(test); })
999             .map(function (test) {
1000                 return App.TestProxyForPopup.create({content: test, platform: platform, action: action, position: position});
1001             });
1002
1003         var metrics = this.get('metrics')
1004             .filter(function (metric) { return platform.containsMetric(metric); })
1005             .map(function (metric) {
1006                 var aggregator = metric.get('aggregator');
1007                 return {
1008                     actionName: action,
1009                     actionArgument: {platform: platform, metric: metric, position:position},
1010                     label: metric.get('label')
1011                 };
1012             });
1013
1014         if (childTests.length && metrics.length)
1015             metrics.push({isSeparator: true});
1016         else if (metrics.length == 1) {
1017             this._actionName = action;
1018             this._actionArgument = metrics[0].actionArgument;
1019             return;
1020         }
1021
1022         this._actionName = null;
1023         this._actionArgument = null;
1024         this._children = metrics.concat(childTests);
1025     },
1026 });
1027
1028 App.domainsAreEqual = function (domain1, domain2) {
1029     return (!domain1 && !domain2) || (domain1 && domain2 && !(domain1[0] - domain2[0]) && !(domain1[1] - domain2[1]));
1030 }
1031
1032 App.PaneController = Ember.ObjectController.extend({
1033     needs: ['charts'],
1034     sharedTime: Ember.computed.alias('parentController.sharedTime'),
1035     sharedSelection: Ember.computed.alias('parentController.sharedSelection'),
1036     selection: null,
1037     actions: {
1038         toggleDetails: function()
1039         {
1040             this.toggleProperty('showingDetails');
1041         },
1042         close: function ()
1043         {
1044             this.parentController.removePane(this.get('model'));
1045         },
1046         toggleBugsPane: function ()
1047         {
1048             if (this.toggleProperty('showingAnalysisPane')) {
1049                 this.set('showingSearchPane', false);
1050                 this.set('showingStatPane', false);
1051             }
1052         },
1053         toggleShowOutlier: function ()
1054         {
1055             this.get('model').toggleProperty('showOutlier');
1056         },
1057         createAnalysisTask: function ()
1058         {
1059             var name = this.get('newAnalysisTaskName');
1060             var points = this.get('selectedPoints');
1061             Ember.assert('The analysis name should not be empty', name);
1062             Ember.assert('There should be at least two points in the range', points && points.length >= 2);
1063
1064             var newWindow = window.open();
1065             var self = this;
1066             App.AnalysisTask.create(name, points[0].measurement, points[points.length - 1].measurement).then(function (data) {
1067                 // FIXME: Update the UI to show the new analysis task.
1068                 var url = App.Router.router.generate('analysisTask', data['taskId']);
1069                 newWindow.location.href = '#' + url;
1070                 self.get('model').fetchAnalyticRanges();
1071             }, function (error) {
1072                 newWindow.close();
1073                 if (error === 'DuplicateAnalysisTask') {
1074                     // FIXME: Duplicate this error more gracefully.
1075                 }
1076                 alert(error);
1077             });
1078         },
1079         toggleSearchPane: function ()
1080         {
1081             if (!App.Manifest.repositoriesWithReportedCommits)
1082                 return;
1083             var model = this.get('model');
1084             if (!model.get('commitSearchRepository'))
1085                 model.set('commitSearchRepository', App.Manifest.repositoriesWithReportedCommits[0]);
1086             if (this.toggleProperty('showingSearchPane')) {
1087                 this.set('showingAnalysisPane', false);
1088                 this.set('showingStatPane', false);
1089             }
1090         },
1091         searchCommit: function () {
1092             var model = this.get('model');
1093             model.searchCommit(model.get('commitSearchRepository'), model.get('commitSearchKeyword'));                
1094         },
1095         toggleStatPane: function ()
1096         {
1097             if (this.toggleProperty('showingStatPane')) {
1098                 this.set('showingSearchPane', false);
1099                 this.set('showingAnalysisPane', false);
1100             }
1101         },
1102         zoomed: function (selection)
1103         {
1104             this.set('mainPlotDomain', selection ? selection : this.get('overviewDomain'));
1105             if (selection)
1106                 this.set('overviewSelection', selection);
1107             Ember.run.debounce(this, 'propagateZoom', 100);
1108         },
1109     },
1110     _overviewSelectionChanged: function ()
1111     {
1112         var overviewSelection = this.get('overviewSelection');
1113         if (App.domainsAreEqual(overviewSelection, this.get('mainPlotDomain')))
1114             return;
1115         this.set('mainPlotDomain', overviewSelection || this.get('overviewDomain'));
1116         Ember.run.debounce(this, 'propagateZoom', 100);
1117     }.observes('overviewSelection'),
1118     _sharedDomainChanged: function ()
1119     {
1120         var newDomain = this.get('parentController').get('sharedDomain');
1121         if (App.domainsAreEqual(newDomain, this.get('overviewDomain')))
1122             return;
1123         this.set('overviewDomain', newDomain);
1124         if (!this.get('overviewSelection'))
1125             this.set('mainPlotDomain', newDomain);
1126     }.observes('parentController.sharedDomain').on('init'),
1127     propagateZoom: function ()
1128     {
1129         this.get('parentController').set('sharedZoom', this.get('mainPlotDomain'));
1130     },
1131     _sharedZoomChanged: function ()
1132     {
1133         var newSelection = this.get('parentController').get('sharedZoom');
1134         if (App.domainsAreEqual(newSelection, this.get('mainPlotDomain')))
1135             return;
1136         this.set('mainPlotDomain', newSelection || this.get('overviewDomain'));
1137         this.set('overviewSelection', newSelection);
1138     }.observes('parentController.sharedZoom').on('init'),
1139     _updateCanAnalyze: function ()
1140     {
1141         var pane = this.get('model');
1142         var points = pane.get('selectedPoints');
1143         this.set('cannotAnalyze', !this.get('newAnalysisTaskName') || !points || points.length < 2);
1144         this.set('cannotMarkOutlier', !!points || !this.get('selectedItem'));
1145
1146         var selectedMeasurement = this.selectedMeasurement();
1147         this.set('selectedItemIsMarkedOutlier', selectedMeasurement && selectedMeasurement.markedOutlier());
1148
1149     }.observes('newAnalysisTaskName', 'model.selectedPoints', 'model.selectedItem').on('init'),
1150     selectedMeasurement: function () {
1151         var chartData = this.get('model').get('chartData');
1152         var selectedItem = this.get('selectedItem');
1153         if (!chartData || !selectedItem)
1154             return null;
1155         var point = chartData.current.findPointByMeasurementId(selectedItem);
1156         Ember.assert('selectedItem should always be in the current chart data', point);
1157         return point.measurement;
1158     },
1159     showOutlierTitle: function ()
1160     {
1161         return this.get('showOutlier') ? 'Hide outliers' : 'Show outliers';
1162     }.property('showOutlier'),
1163     _selectedItemIsMarkedOutlierDidChange: function ()
1164     {
1165         var selectedMeasurement = this.selectedMeasurement();
1166         if (!selectedMeasurement)
1167             return;
1168         var selectedItemIsMarkedOutlier = this.get('selectedItemIsMarkedOutlier');
1169         if (selectedMeasurement.markedOutlier() == selectedItemIsMarkedOutlier)
1170             return;
1171         var pane = this.get('model');
1172         selectedMeasurement.setMarkedOutlier(!!selectedItemIsMarkedOutlier).then(function () {
1173             alert(selectedItemIsMarkedOutlier ? 'Marked the point as an outlier' : 'The point is no longer marked as an outlier');
1174             pane.refetchRuns();
1175         }, function (error) {
1176             alert('Failed to update the status:' + error);
1177         });
1178     }.observes('selectedItemIsMarkedOutlier'),
1179     alternativePanes: function ()
1180     {
1181         var pane = this.get('model');
1182         var metric = pane.get('metric');
1183         var currentPlatform = pane.get('platform');
1184         var platforms = App.Manifest.get('platforms');
1185         if (!platforms || !metric)
1186             return;
1187
1188         var exitingPlatforms = {};
1189         this.get('parentController').get('panes').forEach(function (pane) {
1190             if (pane.get('metricId') == metric.get('id'))
1191                 exitingPlatforms[pane.get('platformId')] = true;
1192         });
1193
1194         var alternativePanes = platforms.filter(function (platform) {
1195             return !exitingPlatforms[platform.get('id')] && platform.containsMetric(metric);
1196         }).map(function (platform) {
1197             return {
1198                 pane: pane,
1199                 platform: platform,
1200                 metrics: [metric],
1201                 label: platform.get('label')
1202             };
1203         });
1204
1205         var childMetrics = metric.get('childMetrics');
1206         if (childMetrics && childMetrics.length) {
1207             alternativePanes.push({
1208                 pane: pane,
1209                 platform: currentPlatform,
1210                 metrics: childMetrics,
1211                 label: 'Breakdown',
1212             });
1213         }
1214
1215         return alternativePanes;
1216     }.property('model.metric', 'model.platform', 'App.Manifest.platforms',
1217         'parentController.panes.@each.platformId', 'parentController.panes.@each.metricId'),
1218 });
1219
1220 App.AnalysisRoute = Ember.Route.extend({
1221     model: function () {
1222         var store = this.store;
1223         return App.Manifest.fetch(store).then(function () {
1224             return store.findAll('analysisTask').then(function (tasks) {
1225                 return Ember.Object.create({'tasks': tasks.sortBy('createdAt').toArray().reverse()});
1226             });
1227         });
1228     },
1229 });
1230
1231 App.AnalysisTaskRoute = Ember.Route.extend({
1232     model: function (param)
1233     {
1234         var store = this.store;
1235         return App.Manifest.fetch(store).then(function () {
1236             return store.find('analysisTask', param.taskId);
1237         });
1238     },
1239 });
1240
1241 App.AnalysisTaskController = Ember.Controller.extend({
1242     label: Ember.computed.alias('model.name'),
1243     platform: Ember.computed.alias('model.platform'),
1244     metric: Ember.computed.alias('model.metric'),
1245     details: Ember.computed.alias('pane.details'),
1246     roots: [],
1247     bugTrackers: [],
1248     possibleRepetitionCounts: [1, 2, 3, 4, 5, 6],
1249     analysisResultOptions: [
1250         {label: 'Still in investigation', result: null},
1251         {label: 'Inconclusive', result: 'inconclusive', needed: true},
1252         {label: 'Definite progression', result: 'progression', needed: true},
1253         {label: 'Definite regression', result: 'regression', needed: true},
1254         {label: 'No change', result: 'unchanged', needsFeedback: true},
1255     ],
1256     shouldNotHaveBeenCreated: false,
1257     needsFeedback: function ()
1258     {
1259         var chosen = this.get('chosenAnalysisResult');
1260         return chosen && chosen.needsFeedback;
1261     }.property('chosenAnalysisResult'),
1262     _updateChosenAnalysisResult: function ()
1263     {
1264         var analysisTask = this.get('model');
1265         if (!analysisTask)
1266             return;
1267         var currentResult = analysisTask.get('result');
1268         for (var option of this.analysisResultOptions) {
1269             if (option.result == currentResult) {
1270                 this.set('chosenAnalysisResult', option);
1271                 break;                
1272             }
1273         }
1274     }.observes('model'),
1275     _taskUpdated: function ()
1276     {
1277         var model = this.get('model');
1278         if (!model)
1279             return;
1280
1281         App.Manifest.fetch(this.store).then(this._fetchedManifest.bind(this));
1282         this.set('pane', App.Pane.create({
1283             store: this.store,
1284             platformId: model.get('platform').get('id'),
1285             metricId: model.get('metric').get('id'),
1286         }));
1287
1288         var self = this;
1289         model.get('testGroups').then(function (groups) {
1290             self.set('testGroupPanes', groups.map(function (group) { return App.TestGroupPane.create({content: group}); }));
1291         });
1292     }.observes('model', 'model.testGroups').on('init'),
1293     _fetchedManifest: function ()
1294     {
1295         var trackerIdToBugNumber = {};
1296         this.get('model').get('bugs').forEach(function (bug) {
1297             trackerIdToBugNumber[bug.get('bugTracker').get('id')] = bug.get('number');
1298         });
1299
1300         this.set('bugTrackers', App.Manifest.get('bugTrackers').map(function (bugTracker) {
1301             var bugNumber = trackerIdToBugNumber[bugTracker.get('id')];
1302             return Ember.ObjectProxy.create({
1303                 elementId: 'bug-' + bugTracker.get('id'),
1304                 content: bugTracker,
1305                 bugNumber: bugNumber,
1306                 bugUrl: bugTracker.urlFromBugNumber(bugNumber),
1307                 editedBugNumber: bugNumber,
1308             });
1309         }));
1310     },
1311     _chartDataChanged: function ()
1312     {
1313         var pane = this.get('pane');
1314         if (!pane)
1315             return;
1316
1317         var chartData = pane.get('chartData');
1318         if (!chartData)
1319             return null;
1320
1321         var currentTimeSeries = chartData.current;
1322         Ember.assert('chartData.current should always be defined', currentTimeSeries);
1323
1324         var start = currentTimeSeries.findPointByMeasurementId(this.get('model').get('startRun'));
1325         var end = currentTimeSeries.findPointByMeasurementId(this.get('model').get('endRun'));
1326         if (!start || !end) {
1327             if (!pane.get('showOutlier'))
1328                 pane.set('showOutlier', true);
1329             return;
1330         }
1331
1332         var highlightedItems = {};
1333         highlightedItems[start.measurement.id()] = true;
1334         highlightedItems[end.measurement.id()] = true;
1335
1336         var formatedPoints = currentTimeSeries.seriesBetweenPoints(start, end).map(function (point, index) {
1337             return {
1338                 id: point.measurement.id(),
1339                 measurement: point.measurement,
1340                 label: 'Point ' + (index + 1),
1341                 value: chartData.formatter(point.value),
1342             };
1343         });
1344
1345         var margin = (end.time - start.time) * 0.1;
1346         this.set('highlightedItems', highlightedItems);
1347         this.set('overviewEndPoints', [start, end]);
1348         this.set('analysisPoints', formatedPoints);
1349
1350         var overviewDomain = [start.time - margin, +end.time + margin];
1351
1352         var testGroupPanes = this.get('testGroupPanes');
1353         if (testGroupPanes) {
1354             testGroupPanes.setEach('overviewPane', pane);
1355             testGroupPanes.setEach('overviewDomain', overviewDomain);
1356         }
1357
1358         this.set('overviewDomain', overviewDomain);
1359     }.observes('pane.chartData'),
1360     updateRootConfigurations: function ()
1361     {
1362         var analysisPoints = this.get('analysisPoints');
1363         if (!analysisPoints)
1364             return;
1365         var repositoryToRevisions = {};
1366         analysisPoints.forEach(function (point, pointIndex) {
1367             var revisions = point.measurement.formattedRevisions();
1368             for (var repositoryId in revisions) {
1369                 if (!repositoryToRevisions[repositoryId])
1370                     repositoryToRevisions[repositoryId] = new Array();
1371                 var revision = revisions[repositoryId];
1372                 repositoryToRevisions[repositoryId].push({time: point.measurement.latestCommitTime(), value: revision.currentRevision});
1373             }
1374         });
1375
1376         var commitsPromises = [];
1377         var repositoryToIndex = {};
1378         for (var repositoryId in repositoryToRevisions) {
1379             var revisions = repositoryToRevisions[repositoryId].sort(function (a, b) { return a.time - b.time; });
1380             repositoryToIndex[repositoryId] = commitsPromises.length;
1381             commitsPromises.push(CommitLogs.fetchCommits(repositoryId, revisions[0].value, revisions[revisions.length - 1].value));
1382         }
1383
1384         var self = this;
1385         this.get('model').get('triggerable').then(function (triggerable) {
1386             if (!triggerable)
1387                 return;
1388             Ember.RSVP.Promise.all(commitsPromises).then(function (commitsList) {
1389                 self.set('configurations', ['A', 'B']);
1390                 self.set('rootConfigurations', triggerable.get('acceptedRepositories').map(function (repository) {
1391                     return self._createConfiguration(repository, commitsList[repositoryToIndex[repository.get('id')]], analysisPoints);
1392                 }));
1393             });
1394         });
1395     }.observes('analysisPoints'),
1396     _createConfiguration: function(repository, commits, analysisPoints) {
1397         var repositoryId = repository.get('id');
1398
1399         var revisionToPoints = {};
1400         var missingPoints = [];
1401         analysisPoints.forEach(function (point, pointIndex) {
1402             var revision = point.measurement.revisionForRepository(repositoryId);
1403             if (!revision) {
1404                 missingPoints.push(pointIndex);
1405                 return;
1406             }
1407             if (!revisionToPoints[revision])
1408                 revisionToPoints[revision] = [];
1409             revisionToPoints[revision].push(pointIndex);
1410         });
1411
1412         if (!commits || !commits.length) {
1413             commits = [];
1414             for (var revision in revisionToPoints)
1415                 commits.push({revision: revision});
1416         }
1417
1418         var options = [{label: 'None'}, {label: 'Custom', isCustom: true}];
1419         if (missingPoints.length) {
1420             options[0].label += ' ' + this._labelForPoints(missingPoints);
1421             options[0].points = missingPoints;
1422         }
1423
1424         for (var commit of commits) {
1425             var revision = commit.revision;
1426             var points = revisionToPoints[revision];
1427             var label = Measurement.formatRevisionRange(revision).label;
1428             if (points)
1429                 label += ' ' + this._labelForPoints(points);
1430             options.push({value: revision, label: label, points: points});
1431         }
1432
1433         var firstOption = null;
1434         var lastOption = null;
1435         for (var option of options) {
1436             var points = option.points;
1437             if (!points)
1438                 continue;
1439             if (points.indexOf(0) >= 0)
1440                 firstOption = option;
1441             if (points.indexOf(analysisPoints.length - 1) >= 0)
1442                 lastOption = option;
1443         }
1444
1445         return Ember.Object.create({
1446             repository: repository,
1447             name: repository.get('name'),
1448             sets: [
1449                 Ember.Object.create({name: 'A[' + repositoryId + ']',
1450                     options: options,
1451                     selection: firstOption}),
1452                 Ember.Object.create({name: 'B[' + repositoryId + ']',
1453                     options: options,
1454                     selection: lastOption}),
1455             ]});
1456     },
1457     _labelForPoints: function (points)
1458     {
1459         var serializedPoints = this._serializeNumbersSkippingConsecutiveEntries(points);
1460         return ['(', points.length > 1 ? 'points' : 'point', serializedPoints, ')'].join(' ');
1461     },
1462     _serializeNumbersSkippingConsecutiveEntries: function (numbers) {
1463         var result = numbers[0];
1464         for (var i = 1; i < numbers.length; i++) {
1465             if (numbers[i - 1] + 1 == numbers[i]) {
1466                 while (numbers[i] + 1 == numbers[i + 1])
1467                     i++;
1468                 result += '-' + numbers[i];
1469                 continue;
1470             }
1471             result += ', ' + numbers[i]
1472         }
1473         return result;
1474     },
1475     actions: {
1476         addBug: function (bugTracker, bugNumber)
1477         {
1478             var model = this.get('model');
1479             if (!bugTracker)
1480                 bugTracker = this.get('bugTrackers').objectAt(0);
1481             var bug = {task: this.get('model'), bugTracker: bugTracker.get('content'), number: bugNumber};
1482             this.store.createRecord('bug', bug).save().then(function () {
1483                 alert('Associated the ' + bugTracker.get('name') + ' ' + bugNumber + ' with this analysis.');
1484             }, function (error) {
1485                 alert('Failed to associate the bug: ' + error);
1486             });
1487         },
1488         deleteBug: function (bug)
1489         {
1490             bug.destroyRecord().then(function () {
1491                 alert('Successfully deassociated the bug.');
1492             }, function (error) {
1493                 alert('Failed to disassociate the bug: ' + error);
1494             });
1495         },
1496         saveStatus: function ()
1497         {
1498             var chosenResult = this.get('chosenAnalysisResult');
1499             var analysisTask = this.get('model');
1500             analysisTask.set('result', chosenResult.result);
1501             if (chosenResult.needed)
1502                 analysisTask.set('needed', true);
1503             else if (chosenResult.needsFeedback && this.get('notNeeded'))
1504                 analysisTask.set('needed', false);
1505             else
1506                 analysisTask.set('needed', null);
1507
1508             analysisTask.saveStatus().then(function () {
1509                 alert('Saved the status');
1510             }, function (error) {
1511                 alert('Failed to save the status: ' + error);
1512             });
1513         },
1514         createTestGroup: function (name, repetitionCount)
1515         {
1516             var analysisTask = this.get('model');
1517             if (analysisTask.get('testGroups').isAny('name', name)) {
1518                 alert('Cannot create two test groups of the same name.');
1519                 return;
1520             }
1521
1522             var roots = {};
1523             var rootConfigurations = this.get('rootConfigurations').toArray();
1524             for (var root of rootConfigurations) {
1525                 var sets = root.get('sets');
1526                 var hasSelection = function (item) {
1527                     var selection = item.get('selection');
1528                     return selection.value || (selection.isCustom && item.get('customValue'));
1529                 }
1530                 if (!sets.any(hasSelection))
1531                     continue;
1532                 if (!sets.every(hasSelection)) {
1533                     alert('Only some configuration specifies ' + root.get('name'));
1534                     return;
1535                 }
1536                 roots[root.get('name')] = sets.map(function (item) {
1537                     var selection = item.get('selection');
1538                     return selection.isCustom ? item.get('customValue') : selection.value;
1539                 });
1540             }
1541
1542             App.TestGroup.create(analysisTask, name, roots, repetitionCount).then(function () {
1543             }, function (error) {
1544                 alert('Failed to create a new test group:' + error);
1545             });
1546         },
1547         toggleShowRequestList: function (configuration)
1548         {
1549             configuration.toggleProperty('showRequestList');
1550         }
1551     },
1552     _updateRootsBySelectedPoints: function ()
1553     {
1554         var rootConfigurations = this.get('rootConfigurations');
1555         var pane = this.get('pane');
1556         if (!rootConfigurations || !pane)
1557             return;
1558
1559         var rootSetPoints;
1560         var selectedPoints = pane.get('selectedPoints');
1561         if (selectedPoints && selectedPoints.length >= 2)
1562             rootSetPoints = [selectedPoints[0], selectedPoints[selectedPoints.length - 1]];
1563         else
1564             rootSetPoints = this.get('overviewEndPoints');
1565         if (!rootSetPoints)
1566             return;
1567
1568         rootConfigurations.forEach(function (root) {
1569             root.get('sets').forEach(function (set, setIndex) {
1570                 if (setIndex >= rootSetPoints.length)
1571                     return;
1572                 var targetRevision = rootSetPoints[setIndex].measurement.revisionForRepository(root.get('repository').get('id'));
1573                 var selectedOption;
1574                 if (targetRevision)
1575                     selectedOption = set.get('options').find(function (option) { return option.value == targetRevision; });
1576                 set.set('selection', selectedOption || set.get('options')[0]);
1577             });
1578         });
1579
1580     }.observes('pane.selectedPoints'),
1581 });
1582
1583 App.TestGroupPane = Ember.ObjectProxy.extend({
1584     _populate: function ()
1585     {
1586         var buildRequests = this.get('buildRequests');
1587         var testResults = this.get('testResults');
1588         if (!buildRequests || !testResults)
1589             return [];
1590
1591         var repositories = this._computeRepositoryList();
1592         this.set('repositories', repositories);
1593
1594         var requestsByRooSet = this._groupRequestsByConfigurations(buildRequests);
1595
1596         var configurations = [];
1597         var index = 0;
1598         var range = {min: Infinity, max: -Infinity};
1599         for (var rootSetId in requestsByRooSet) {
1600             var configLetter = String.fromCharCode('A'.charCodeAt(0) + index++);
1601             configurations.push(this._createConfigurationSummary(requestsByRooSet[rootSetId], configLetter, range));
1602         }
1603
1604         var margin = 0.1 * (range.max - range.min);
1605         range.max += margin;
1606         range.min -= margin;
1607
1608         this.set('configurations', configurations);
1609
1610         var probabilityFormatter = d3.format('.2p');
1611         var comparisons = [];
1612         for (var i = 0; i < configurations.length - 1; i++) {
1613             var summary1 = configurations[i].summary;
1614             for (var j = i + 1; j < configurations.length; j++) {
1615                 var summary2 = configurations[j].summary;
1616
1617                 var valueDelta = testResults.deltaFormatter(summary2.value - summary1.value);
1618                 var relativeDelta = d3.format('+.2p')((summary2.value - summary1.value) / summary1.value);
1619
1620                 var stat = this._computeStatisticalSignificance(summary1.measuredValues, summary2.measuredValues);
1621                 comparisons.push({
1622                     label: summary1.configLetter + ' / ' + summary2.configLetter,
1623                     difference: isNaN(summary1.value) || isNaN(summary2.value) ? 'N/A' : valueDelta + ' (' + relativeDelta + ') ',
1624                     result: stat,
1625                 });
1626             }
1627         }
1628         this.set('comparisons', comparisons);
1629     }.observes('testResults', 'buildRequests'),
1630     _computeStatisticalSignificance: function (values1, values2)
1631     {
1632         var tFormatter = d3.format('.3g');
1633         var probabilityFormatter = d3.format('.2p');
1634         var statistics = Statistics.probabilityRangeForWelchsT(values1, values2);
1635         if (isNaN(statistics.t) || isNaN(statistics.degreesOfFreedom))
1636             return 'N/A';
1637
1638         var details = ' (t=' + tFormatter(statistics.t) + ' df=' + tFormatter(statistics.degreesOfFreedom) + ')';
1639
1640         if (!statistics.range[0])
1641             return 'Not significant' + details;
1642
1643         var lowerLimit = probabilityFormatter(statistics.range[0]);
1644         if (!statistics.range[1])
1645             return 'Significance > ' + lowerLimit + details;
1646
1647         return lowerLimit + ' < Significance < ' + probabilityFormatter(statistics.range[1]) + details;
1648     },
1649     _updateReferenceChart: function ()
1650     {
1651         var configurations = this.get('configurations');
1652         var chartData = this.get('overviewPane') ? this.get('overviewPane').get('chartData') : null;
1653         if (!configurations || !chartData || this.get('referenceChart'))
1654             return;
1655
1656         var currentTimeSeries = chartData.current;
1657         if (!currentTimeSeries)
1658             return;
1659
1660         var repositories = this.get('repositories');
1661         var highlightedItems = {};
1662         var failedToFindPoint = false;
1663         configurations.forEach(function (config) {
1664             var revisions = {};
1665             config.get('rootSet').get('roots').forEach(function (root) {
1666                 revisions[root.get('repository').get('id')] = root.get('revision');
1667             });
1668             var point = currentTimeSeries.findPointByRevisions(revisions);
1669             if (!point) {
1670                 failedToFindPoint = true;
1671                 return;
1672             }
1673             highlightedItems[point.measurement.id()] = true;
1674         });
1675         if (failedToFindPoint)
1676             return;
1677
1678         this.set('referenceChart', {
1679             data: chartData,
1680             highlightedItems: highlightedItems,
1681         });
1682     }.observes('configurations', 'overviewPane.chartData'),
1683     _computeRepositoryList: function ()
1684     {
1685         var specifiedRepositories = new Ember.Set();
1686         (this.get('rootSets') || []).forEach(function (rootSet) {
1687             (rootSet.get('roots') || []).forEach(function (root) {
1688                 specifiedRepositories.add(root.get('repository'));
1689             });
1690         });
1691         var reportedRepositories = new Ember.Set();
1692         var testResults = this.get('testResults');
1693         (this.get('buildRequests') || []).forEach(function (request) {
1694             var point = testResults.current.findPointByBuild(request.get('build'));
1695             if (!point)
1696                 return;
1697
1698             var revisionByRepositoryId = point.measurement.formattedRevisions();
1699             for (var repositoryId in revisionByRepositoryId) {
1700                 var repository = App.Manifest.repository(repositoryId);
1701                 if (!specifiedRepositories.contains(repository))
1702                     reportedRepositories.add(repository);
1703             }
1704         });
1705         return specifiedRepositories.sortBy('name').concat(reportedRepositories.sortBy('name'));
1706     },
1707     _groupRequestsByConfigurations: function (requests, repositoryList)
1708     {
1709         var rootSetIdToRequests = {};
1710         var testGroup = this;
1711         requests.forEach(function (request) {
1712             var rootSetId = request.get('rootSet').get('id');
1713             if (!rootSetIdToRequests[rootSetId])
1714                 rootSetIdToRequests[rootSetId] = [];
1715             rootSetIdToRequests[rootSetId].push(request);
1716         });
1717         return rootSetIdToRequests;
1718     },
1719     _createConfigurationSummary: function (buildRequests, configLetter, range)
1720     {
1721         var repositories = this.get('repositories');
1722         var testResults = this.get('testResults');
1723         var requests = buildRequests.map(function (originalRequest) {
1724             var point = testResults.current.findPointByBuild(originalRequest.get('build'));
1725             var revisionByRepositoryId = point ? point.measurement.formattedRevisions() : {};
1726             return Ember.ObjectProxy.create({
1727                 content: originalRequest,
1728                 revisionList: repositories.map(function (repository, index) {
1729                     return (revisionByRepositoryId[repository.get('id')] || {label:null}).label;
1730                 }),
1731                 value: point ? point.value : null,
1732                 valueRange: range,
1733                 formattedValue: point ? testResults.formatter(point.value) : null,
1734                 buildLabel: point ? 'Build ' + point.measurement.buildNumber() : null,
1735             });
1736         });
1737
1738         var rootSet = requests ? requests[0].get('rootSet') : null;
1739         var summaryRevisions = repositories.map(function (repository, index) {
1740             var revision = rootSet ? rootSet.revisionForRepository(repository) : null;
1741             if (!revision)
1742                 return requests[0].get('revisionList')[index];
1743             return Measurement.formatRevisionRange(revision).label;
1744         });
1745
1746         requests.forEach(function (request) {
1747             var revisionList = request.get('revisionList');
1748             repositories.forEach(function (repository, index) {
1749                 if (revisionList[index] == summaryRevisions[index])
1750                     revisionList[index] = null;
1751             });
1752         });
1753
1754         var valuesInConfig = requests.mapBy('value').filter(function (value) { return typeof(value) === 'number' && !isNaN(value); });
1755         var sum = Statistics.sum(valuesInConfig);
1756         var ciDelta = Statistics.confidenceIntervalDelta(0.95, valuesInConfig.length, sum, Statistics.squareSum(valuesInConfig));
1757         var mean = sum / valuesInConfig.length;
1758
1759         range.min = Math.min(range.min, Statistics.min(valuesInConfig));
1760         range.max = Math.max(range.max, Statistics.max(valuesInConfig));
1761         if (ciDelta && !isNaN(ciDelta)) {
1762             range.min = Math.min(range.min, mean - ciDelta);
1763             range.max = Math.max(range.max, mean + ciDelta);
1764         }
1765
1766         var summary = Ember.Object.create({
1767             isAverage: true,
1768             configLetter: configLetter,
1769             revisionList: summaryRevisions,
1770             formattedValue: isNaN(mean) ? null : testResults.formatWithDeltaAndUnit(mean, ciDelta),
1771             value: mean,
1772             measuredValues: valuesInConfig,
1773             confidenceIntervalDelta: ciDelta,
1774             valueRange: range,
1775             statusLabel: App.BuildRequest.aggregateStatuses(requests),
1776         });
1777
1778         return Ember.Object.create({summary: summary, requests: requests, rootSet: rootSet});
1779     },
1780 });
1781
1782 App.BoxPlotComponent = Ember.Component.extend({
1783     classNames: ['box-plot'],
1784     range: null,
1785     value: null,
1786     delta: null,
1787     didInsertElement: function ()
1788     {
1789         var element = this.get('element');
1790         var svg = d3.select(element).append('svg')
1791             .attr('viewBox', '0 0 100 20')
1792             .attr('preserveAspectRatio', 'none')
1793             .style({width: '100%', height: '100%'});
1794
1795         this._percentageRect = svg
1796             .append('rect')
1797             .attr('x', 0)
1798             .attr('y', 0)
1799             .attr('width', 0)
1800             .attr('height', 20)
1801             .attr('class', 'percentage');
1802
1803         this._deltaRect = svg
1804             .append('rect')
1805             .attr('x', 0)
1806             .attr('y', 5)
1807             .attr('width', 0)
1808             .attr('height', 10)
1809             .attr('class', 'delta')
1810             .attr('opacity', 0.5)
1811         this._updateBars();
1812     },
1813     _updateBars: function ()
1814     {
1815         if (!this._percentageRect || typeof(this._percentage) !== 'number' || isNaN(this._percentage))
1816             return;
1817
1818         this._percentageRect.attr('width', this._percentage);
1819         if (typeof(this._delta) === 'number' && !isNaN(this._delta)) {
1820             this._deltaRect.attr('x', this._percentage - this._delta);
1821             this._deltaRect.attr('width', this._delta * 2);
1822         }
1823     },
1824     valueChanged: function ()
1825     {
1826         var range = this.get('range');
1827         var value = this.get('value');
1828         if (!range || !value)
1829             return;
1830         var scalingFactor = 100 / (range.max - range.min);
1831         var percentage = (value - range.min) * scalingFactor;
1832         this._percentage = percentage;
1833         this._delta = this.get('delta') * scalingFactor;
1834         this._updateBars();
1835     }.observes('value', 'range').on('init'),
1836 });