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