New perf dashboard should provide UI to create a new analysis task
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 20 Nov 2014 23:25:07 +0000 (23:25 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 20 Nov 2014 23:25:07 +0000 (23:25 +0000)
https://bugs.webkit.org/show_bug.cgi?id=138910

Reviewed by Benjamin Poulain.

This patch reverts some parts of r175006 and re-introduces bugs associated with analysis tasks.
I'll add UI to show and edit bug numbers associated with an analysis task in a follow up patch.

With this patch, we can create a new analysis task by selection a range of points and opening
"analysis pane" (renamed from "bugs pane"). Each analysis task created is represented by a yellow bar
in the chart hyperlinked to the analysis task.

* init-database.sql: Redefined the bugs to be associated with an analysis task instead of a test run.

* public/api/analysis-tasks.php: Added the support for querying analysis tasks for a specific metric
on a specific platform. Also retrieve and return all bugs associated with analysis tasks.
(main):
(fetch_and_push_bugs_to_tasks): Added. Fetches all bugs associated with an array of analysis tasks
and adds the associated bugs to each task in the array.
(format_task):

* public/api/runs.php: Reverted changes made in r175006.
(fetch_runs_for_config):
(format_run):

* public/api/test-groups.php:
(fetch_test_groups_for_task): Use the newly added Database::select_rows.

* public/include/db.php:
(Database::select_first_or_last_row):
(Database::select_rows): Extracted from select_first_or_last_row.

* public/v2/analysis.js:
(App.AnalysisTask): Added "bugs" property.
(App.Bug): Added now that bugs are regular data store objects.

* public/v2/app.js:
(App.Pane._fetch): Calls this.fetchAnalyticRanges to fetch analysis tasks as well as test runs.
(App.Pane.fetchAnalyticRanges): Added. Fetches analysis tasks for the current metric on the current
platform that are associated with a specific range of runs.
(App.PaneController.actions.toggleBugsPane): Updated per showingBugsPane to showingAnalysisPane rename.
(App.PaneController.actions.associateBug): Deleted.
(App.PaneController.actions.createAnalysisTask): Replaced the pre-condition checks with assertions as
this action should never be triggered when the pre-condition is not met. Also re-fetch analysis tasks
once we've created one.
(App.PaneController.toggleSearchPane): Updated per showingBugsPane to showingAnalysisPane rename.
(App.PaneController._detailsChanged): Ditto. Removed selectedSinglePoint since it's no longer used.
(App.PaneController._showDetails): Call _updateCanAnalyze to update the status of "Analyze" button.
(App.PaneController._updateBugs): Deleted.
(App.PaneController._updateMarkedPoints): Deleted.
(App.PaneController._updateCanAnalyze): Added. Disables the button to create an analysis task when
the name is missing or when at most one point is selected.

(App.InteractiveChartComponent._constructGraphIfPossible): Update the locations of range rects.
(App.InteractiveChartComponent._relayoutDataAndAxes): Ditto.
(App.InteractiveChartComponent._mousePointInGraph): Don't return a point unless the mouse cursor is
on our svg element to avoid locking the current item when a bar shown for an analysis task is clicked.
(App.InteractiveChartComponent._rangesChanged): Added. Creates an array of objects representing
clickable bars for analysis tasks.
(App.InteractiveChartComponent._updateRangeBarRects): Computes the inline style used by each clickable
bar for analysis tasks to place them at the right location.
(App.InteractiveChartComponent.actions.openRange): Added. Forwards the action to the parent controller.

* public/v2/chart-pane.css:
(.chart .extent): Use the same color as the vertical indicator in the highlight behind the selection.
(.chart .rangeBar): Added.

* public/v2/data.js:
(TimeSeries.prototype.nextPoint): Added. Used by _rangesChanged.

* public/v2/index.html: Renamed "bugs pane" to "analysis pane" and removed the UI to associate bugs.
This ability will be reinstated in a follow up patch. Also added a container div and spans for analysis
task bars in the interactive chart component.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@176422 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/init-database.sql
Websites/perf.webkit.org/public/api/analysis-tasks.php
Websites/perf.webkit.org/public/api/runs.php
Websites/perf.webkit.org/public/api/test-groups.php
Websites/perf.webkit.org/public/include/db.php
Websites/perf.webkit.org/public/v2/analysis.js
Websites/perf.webkit.org/public/v2/app.js
Websites/perf.webkit.org/public/v2/chart-pane.css
Websites/perf.webkit.org/public/v2/data.js
Websites/perf.webkit.org/public/v2/index.html

index 3e5680f..7a62082 100644 (file)
@@ -1,3 +1,79 @@
+2014-11-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        New perf dashboard should provide UI to create a new analysis task
+        https://bugs.webkit.org/show_bug.cgi?id=138910
+
+        Reviewed by Benjamin Poulain.
+
+        This patch reverts some parts of r175006 and re-introduces bugs associated with analysis tasks.
+        I'll add UI to show and edit bug numbers associated with an analysis task in a follow up patch.
+
+        With this patch, we can create a new analysis task by selection a range of points and opening
+        "analysis pane" (renamed from "bugs pane"). Each analysis task created is represented by a yellow bar
+        in the chart hyperlinked to the analysis task.
+
+        * init-database.sql: Redefined the bugs to be associated with an analysis task instead of a test run.
+
+        * public/api/analysis-tasks.php: Added the support for querying analysis tasks for a specific metric
+        on a specific platform. Also retrieve and return all bugs associated with analysis tasks.
+        (main):
+        (fetch_and_push_bugs_to_tasks): Added. Fetches all bugs associated with an array of analysis tasks
+        and adds the associated bugs to each task in the array.
+        (format_task):
+
+        * public/api/runs.php: Reverted changes made in r175006.
+        (fetch_runs_for_config):
+        (format_run): 
+
+        * public/api/test-groups.php:
+        (fetch_test_groups_for_task): Use the newly added Database::select_rows.
+
+        * public/include/db.php:
+        (Database::select_first_or_last_row):
+        (Database::select_rows): Extracted from select_first_or_last_row.
+
+        * public/v2/analysis.js:
+        (App.AnalysisTask): Added "bugs" property.
+        (App.Bug): Added now that bugs are regular data store objects.
+
+        * public/v2/app.js:
+        (App.Pane._fetch): Calls this.fetchAnalyticRanges to fetch analysis tasks as well as test runs.
+        (App.Pane.fetchAnalyticRanges): Added. Fetches analysis tasks for the current metric on the current
+        platform that are associated with a specific range of runs.
+        (App.PaneController.actions.toggleBugsPane): Updated per showingBugsPane to showingAnalysisPane rename.
+        (App.PaneController.actions.associateBug): Deleted.
+        (App.PaneController.actions.createAnalysisTask): Replaced the pre-condition checks with assertions as
+        this action should never be triggered when the pre-condition is not met. Also re-fetch analysis tasks
+        once we've created one.
+        (App.PaneController.toggleSearchPane): Updated per showingBugsPane to showingAnalysisPane rename.
+        (App.PaneController._detailsChanged): Ditto. Removed selectedSinglePoint since it's no longer used.
+        (App.PaneController._showDetails): Call _updateCanAnalyze to update the status of "Analyze" button.
+        (App.PaneController._updateBugs): Deleted.
+        (App.PaneController._updateMarkedPoints): Deleted.
+        (App.PaneController._updateCanAnalyze): Added. Disables the button to create an analysis task when
+        the name is missing or when at most one point is selected.
+        
+        (App.InteractiveChartComponent._constructGraphIfPossible): Update the locations of range rects.
+        (App.InteractiveChartComponent._relayoutDataAndAxes): Ditto.
+        (App.InteractiveChartComponent._mousePointInGraph): Don't return a point unless the mouse cursor is
+        on our svg element to avoid locking the current item when a bar shown for an analysis task is clicked.
+        (App.InteractiveChartComponent._rangesChanged): Added. Creates an array of objects representing
+        clickable bars for analysis tasks.
+        (App.InteractiveChartComponent._updateRangeBarRects): Computes the inline style used by each clickable
+        bar for analysis tasks to place them at the right location.
+        (App.InteractiveChartComponent.actions.openRange): Added. Forwards the action to the parent controller.
+
+        * public/v2/chart-pane.css:
+        (.chart .extent): Use the same color as the vertical indicator in the highlight behind the selection.
+        (.chart .rangeBar): Added.
+
+        * public/v2/data.js:
+        (TimeSeries.prototype.nextPoint): Added. Used by _rangesChanged.
+
+        * public/v2/index.html: Renamed "bugs pane" to "analysis pane" and removed the UI to associate bugs.
+        This ability will be reinstated in a follow up patch. Also added a container div and spans for analysis
+        task bars in the interactive chart component.
+
 2014-11-19  Ryosuke Niwa  <rniwa@webkit.org>
 
         Fix typos in r176203.
index 73ed981..ebc98ad 100644 (file)
@@ -130,15 +130,6 @@ CREATE TABLE reports (
     report_failure varchar(64),
     report_failure_details text);
 
-CREATE TABLE bugs (
-    bug_id serial PRIMARY KEY,
-    bug_run integer REFERENCES test_runs NOT NULL,
-    bug_tracker integer REFERENCES bug_trackers NOT NULL,
-    bug_number integer NOT NULL,
-    CONSTRAINT bug_tracker_and_run_must_be_unique UNIQUE(bug_tracker, bug_run));
-CREATE INDEX bugs_tracker_number_index ON bugs(bug_tracker, bug_number);
-CREATE INDEX bugs_run_index ON bugs(bug_run);
-
 CREATE TABLE analysis_tasks (
     task_id serial PRIMARY KEY,
     task_name varchar(256) NOT NULL,
@@ -148,7 +139,16 @@ CREATE TABLE analysis_tasks (
     task_metric integer REFERENCES test_metrics NOT NULL,
     task_start_run integer REFERENCES test_runs,
     task_end_run integer REFERENCES test_runs,
-    CONSTRAINT analysis_task_should_be_unique_for_range UNIQUE(task_start_run, task_end_run));
+    CONSTRAINT analysis_task_should_be_unique_for_range UNIQUE(task_start_run, task_end_run)
+    CONSTRAINT analysis_task_should_not_be_associated_with_single_run
+        CHECK ((task_start_run IS NULL AND task_end_run IS NULL) OR (task_start_run IS NOT NULL AND task_end_run IS NOT NULL)));
+
+CREATE TABLE bugs (
+    bug_id serial PRIMARY KEY,
+    bug_task integer REFERENCES analysis_tasks NOT NULL,
+    bug_tracker integer REFERENCES bug_trackers NOT NULL,
+    bug_number integer NOT NULL,
+    CONSTRAINT bug_task_and_tracker_must_be_unique UNIQUE(bug_task, bug_tracker));
 
 CREATE TABLE analysis_test_groups (
     testgroup_id serial PRIMARY KEY,
index abf0117..82c9cda 100644 (file)
@@ -17,13 +17,47 @@ function main($path) {
             exit_with_error('TaskNotFound', array('id' => $task_id));
         $tasks = array($task);
     } else {
-        // FIXME: Limit the number of tasks we fetch.
-        $tasks = array_reverse($db->fetch_table('analysis_tasks', 'task_created_at'));
+        $metric_id = array_get($_GET, 'metric');
+        $platform_id = array_get($_GET, 'platform');
+        if (!!$metric_id != !!$platform_id)
+            exit_with_error('InvalidArguments', array('metricId' => $metric_id, 'platformId' => $platform_id));
+
+        if ($metric_id)
+            $tasks = $db->select_rows('analysis_tasks', 'task', array('platform' => $platform_id, 'metric' => $metric_id));
+        else {
+            // FIXME: Limit the number of tasks we fetch.
+            $tasks = array_reverse($db->fetch_table('analysis_tasks', 'task_created_at'));
+        }
+
         if (!is_array($tasks))
             exit_with_error('FailedToFetchTasks');
     }
 
-    exit_with_success(array('analysisTasks' => array_map("format_task", $tasks)));
+    $tasks = array_map("format_task", $tasks);
+    $bugs = fetch_and_push_bugs_to_tasks($db, $tasks);
+
+    exit_with_success(array('analysisTasks' => $tasks, 'bugs' => $bugs));
+}
+
+function fetch_and_push_bugs_to_tasks($db, &$tasks) {
+    $task_ids = array();
+    $task_by_id = array();
+    foreach ($tasks as &$task) {
+        array_push($task_ids, $task['id']);
+        $task_by_id[$task['id']] = &$task;
+    }
+
+    $bugs = $db->query_and_fetch_all('SELECT bug_id AS "id", bug_task AS "task", bug_tracker AS "bugTracker", bug_number AS "number"
+        FROM bugs WHERE bug_task = ANY ($1)', array('{' . implode(', ', $task_ids) . '}'));
+    if (!is_array($bugs))
+        exit_with_error('FailedToFetchBugs');
+
+    foreach ($bugs as $bug) {
+        $associated_task = &$task_by_id[$bug['task']];
+        array_push($associated_task['bugs'], $bug['id']);
+    }
+
+    return $bugs;
 }
 
 date_default_timezone_set('UTC');
@@ -37,6 +71,7 @@ function format_task($task_row) {
         'metric' => $task_row['task_metric'],
         'startRun' => $task_row['task_start_run'],
         'endRun' => $task_row['task_end_run'],
+        'bugs' => array(),
     );
 }
 
index ff5766b..adfa166 100644 (file)
@@ -31,13 +31,11 @@ if ($repository_table = $db->fetch_table('repositories')) {
 function fetch_runs_for_config($db, $config) {
     $raw_runs = $db->query_and_fetch_all('
         SELECT test_runs.*, builds.*, array_agg((commit_repository, commit_revision, commit_time)) AS revisions
-            FROM builds LEFT OUTER JOIN build_commits ON commit_build = build_id
-                LEFT OUTER JOIN commits ON build_commit = commit_id,
-                (SELECT test_runs.*, array_agg((bug_tracker, bug_number)) AS bugs
-                    FROM test_runs LEFT OUTER JOIN bugs ON bug_run = run_id WHERE run_config = $1 GROUP BY run_id) as test_runs
-                WHERE run_build = build_id
-                GROUP BY run_id, run_config, run_build, run_mean_cache, run_iteration_count_cache,
-                    run_sum_cache, run_square_sum_cache, bugs, build_id', array($config['config_id']));
+            FROM builds
+                LEFT OUTER JOIN build_commits ON commit_build = build_id
+                LEFT OUTER JOIN commits ON build_commit = commit_id, test_runs
+            WHERE run_build = build_id AND run_config = $1
+            GROUP BY build_id, run_id', array($config['config_id']));
 
     $formatted_runs = array();
     if (!$raw_runs)
@@ -66,19 +64,6 @@ function parse_revisions_array($postgres_array) {
     return $revisions;
 }
 
-function parse_bugs_array($postgres_array) {
-    // e.g. {"(1 /* Bugzilla */, 12345)","(2 /* Radar */, 67890)"}
-    $outer_array = json_decode('[' . trim($postgres_array, '{}') . ']');
-    $bugs = array();
-    foreach ($outer_array as $item) {
-        $raw_data = explode(',', trim($item, '()'));
-        if (!$raw_data[0])
-            continue;
-        $bugs[trim($raw_data[0], '"')] = trim($raw_data[1], '"');
-    }
-    return $bugs;
-}
-
 function format_run($run) {
     return array(
         'id' => intval($run['run_id']),
@@ -87,7 +72,6 @@ function format_run($run) {
         'sum' => floatval($run['run_sum_cache']),
         'squareSum' => floatval($run['run_square_sum_cache']),
         'revisions' => parse_revisions_array($run['revisions']),
-        'bugs' => parse_bugs_array($run['bugs']),
         'buildTime' => strtotime($run['build_time']) * 1000,
         'buildNumber' => intval($run['build_number']),
         'builder' => $run['build_builder']);
index 94c5540..3d7390a 100644 (file)
@@ -43,8 +43,7 @@ function main($path) {
 }
 
 function fetch_test_groups_for_task($db, $task_id) {
-    return $db->query_and_fetch_all('SELECT * FROM analysis_test_groups WHERE testgroup_task = $1
-        ORDER BY testgroup_created_at', array($task_id));
+    return $db->select_rows('analysis_test_groups', 'testgroup', array('task' => $task_id));
 }
 
 function fetch_build_requests_for_task($db, $task_id) {
index 40c90ef..1af4465 100644 (file)
@@ -158,6 +158,13 @@ class Database
     }
 
     private function select_first_or_last_row($table, $prefix, $params, $order_by, $descending_order) {
+        $rows = $this->select_rows($table, $prefix, $params, $order_by, $descending_order, 0, 1);
+        return $rows ? $rows[0] : NULL;
+    }
+
+    function select_rows($table, $prefix, $params,
+        $order_by = NULL, $descending_order = FALSE, $offset = NULL, $limit = NULL) {
+
         $placeholders = array();
         $values = array();
         $column_names = $this->prefixed_column_names($this->prepare_params($params, $placeholders, $values), $prefix);
@@ -169,9 +176,12 @@ class Database
             if ($descending_order)
                 $query .= ' DESC';
         }
-        $rows = $this->query_and_fetch_all($query . ' LIMIT 1', $values);
+        if ($offset !== NULL)
+            $query .= ' OFFSET ' . intval($offset);
+        if ($limit !== NULL)
+            $query .= ' LIMIT ' . intval($limit);
 
-        return $rows ? $rows[0] : NULL;
+        return $this->query_and_fetch_all($query, $values);
     }
 
     function query_and_get_affected_rows($query, $params = array()) {
index 05e4631..08190b5 100644 (file)
@@ -5,11 +5,19 @@ App.AnalysisTask = App.NameLabelModel.extend({
     metric: DS.belongsTo('metric'),
     startRun: DS.attr('number'),
     endRun: DS.attr('number'),
+    bugs: DS.hasMany('bugs'),
     testGroups: function () {
         return this.store.find('testGroup', {task: this.get('id')});
     }.property(),
 });
 
+App.Bug = App.NameLabelModel.extend({
+    task: DS.belongsTo('AnalysisTask'),
+    bugTracker: DS.belongsTo('BugTracker'),
+    createdAt: DS.attr('date'),
+    number: DS.attr('number'),
+});
+
 // FIXME: Use DS.RESTAdapter instead.
 App.AnalysisTask.create = function (name, startMeasurement, endMeasurement)
 {
index 8948f2c..6556d72 100755 (executable)
@@ -358,8 +358,21 @@ App.Pane = Ember.Object.extend({
                 else
                     self.set('failure', 'An internal error');
             });
+
+            this.fetchAnalyticRanges();
         }
     }.observes('platformId', 'metricId').on('init'),
+    fetchAnalyticRanges: function ()
+    {
+        var platformId = this.get('platformId');
+        var metricId = this.get('metricId');
+        var self = this;
+        this.get('store')
+            .find('analysisTask', {platform: platformId, metric: metricId})
+            .then(function (tasks) {
+                self.set('analyticRanges', tasks.filter(function (task) { return task.get('startRun') && task.get('endRun'); }));
+            });
+    },
     _isValidId: function (id)
     {
         if (typeof(id) == "number")
@@ -657,34 +670,23 @@ App.PaneController = Ember.ObjectController.extend({
         },
         toggleBugsPane: function ()
         {
-            if (this.toggleProperty('showingBugsPane'))
+            if (this.toggleProperty('showingAnalysisPane'))
                 this.set('showingSearchPane', false);
         },
-        associateBug: function (bugTracker, bugNumber)
-        {
-            var point = this.get('selectedSinglePoint');
-            if (!point)
-                return;
-            var self = this;
-            point.measurement.associateBug(bugTracker.get('id'), bugNumber).then(function () {
-                self._updateBugs();
-                self._updateMarkedPoints();
-            }, function (error) {
-                alert(error);
-            });
-        },
         createAnalysisTask: function ()
         {
             var name = this.get('newAnalysisTaskName');
             var points = this._selectedPoints;
-            if (!name || !points || points.length < 2)
-                return;
+            Ember.assert('The analysis name should not be empty', name);
+            Ember.assert('There should be at least two points in the range', points && points.length >= 2);
 
             var newWindow = window.open();
+            var self = this;
             App.AnalysisTask.create(name, points[0].measurement, points[points.length - 1].measurement).then(function (data) {
                 // FIXME: Update the UI to show the new analysis task.
                 var url = App.Router.router.generate('analysisTask', data['taskId']);
                 newWindow.location.href = '#' + url;
+                self.get('model').fetchAnalyticRanges();
             }, function (error) {
                 newWindow.close();
                 if (error === 'DuplicateAnalysisTask') {
@@ -701,7 +703,7 @@ App.PaneController = Ember.ObjectController.extend({
             if (!model.get('commitSearchRepository'))
                 model.set('commitSearchRepository', App.Manifest.repositoriesWithReportedCommits[0]);
             if (this.toggleProperty('showingSearchPane'))
-                this.set('showingBugsPane', false);
+                this.set('showingAnalysisPane', false);
         },
         searchCommit: function () {
             var model = this.get('model');
@@ -733,8 +735,7 @@ App.PaneController = Ember.ObjectController.extend({
     },
     _detailsChanged: function ()
     {
-        this.set('showingBugsPane', false);
-        this.set('selectedSinglePoint', !this._hasRange && this._selectedPoints ? this._selectedPoints[0] : null);
+        this.set('showingAnalysisPane', false);
     }.observes('details'),
     _overviewSelectionChanged: function ()
     {
@@ -812,58 +813,13 @@ App.PaneController = Ember.ObjectController.extend({
             buildTime: currentMeasurement.formattedBuildTime(),
             revisions: revisions,
         }));
-        this._updateBugs();
-    },
-    _updateBugs: function ()
-    {
-        if (!this._selectedPoints)
-            return;
-
-        var bugTrackers = App.Manifest.get('bugTrackers');
-        var trackerToBugNumbers = {};
-        bugTrackers.forEach(function (tracker) { trackerToBugNumbers[tracker.get('id')] = new Array(); });
-
-        var points = this._hasRange ? this._selectedPoints : [this._selectedPoints[1]];
-        points.map(function (point) {
-            var bugs = point.measurement.bugs();
-            bugTrackers.forEach(function (tracker) {
-                var bugNumber = bugs[tracker.get('id')];
-                if (bugNumber)
-                    trackerToBugNumbers[tracker.get('id')].push(bugNumber);
-            });
-        });
-
-        this.set('details.bugTrackers', App.Manifest.get('bugTrackers').map(function (tracker) {
-            var bugNumbers = trackerToBugNumbers[tracker.get('id')];
-            return Ember.ObjectProxy.create({
-                content: tracker,
-                bugs: bugNumbers.map(function (bugNumber) {
-                    return {
-                        bugNumber: bugNumber,
-                        bugUrl: bugNumber && tracker.get('bugUrl') ? tracker.get('bugUrl').replace(/\$number/g, bugNumber) : null
-                    };
-                }),
-                editedBugNumber: this._hasRange ? null : bugNumbers[0],
-            }); // FIXME: Create urls for new bugs.
-        }));
+        this._updateCanAnalyze();
     },
-    _updateMarkedPoints: function ()
+    _updateCanAnalyze: function ()
     {
-        var chartData = this.get('chartData');
-        if (!chartData || !chartData.current) {
-            this.set('markedPoints', {});
-            return;
-        }
-
-        var series = chartData.current.timeSeriesByCommitTime().series();
-        var markedPoints = {};
-        for (var i = 0; i < series.length; i++) {
-            var measurement = series[i].measurement;
-            if (measurement.hasBugs())
-                markedPoints[measurement.id()] = true;
-        }
-        this.set('markedPoints', markedPoints);
-    }.observes('chartData'),
+        var points = this._selectedPoints;
+        this.set('cannotAnalyze', !this.get('newAnalysisTaskName') || !this._hasRange || !points || points.length < 2);
+    }.observes('newAnalysisTaskName'),
 });
 
 App.InteractiveChartComponent = Ember.Component.extend({
@@ -1047,6 +1003,8 @@ App.InteractiveChartComponent = Ember.Component.extend({
         setTimeout(this._selectedItemChanged.bind(this), 0);
 
         this._needsConstruction = false;
+
+        this._rangesChanged();
     },
     _updateDomain: function ()
     {
@@ -1142,6 +1100,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
         });
         this._updateMarkedDots();
         this._updateHighlightPositions();
+        this._updateRangeBarRects();
 
         if (this._brush) {
             if (selection)
@@ -1163,7 +1122,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
             this._yAxisUnitContainer.remove();
         this._yAxisUnitContainer = this._yAxisLabels.append("text")
             .attr("x", 0.5 * this._rem)
-            .attr("y", this._rem)
+            .attr("y", 0.2 * this._rem)
             .attr("dy", 0.8 * this._rem)
             .style("text-anchor", "start")
             .style("z-index", "100")
@@ -1350,7 +1309,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
     _mousePointInGraph: function (event)
     {
         var offset = $(this.get('element')).offset();
-        if (!offset)
+        if (!offset || !$(event.target).closest('svg').length)
             return null;
 
         var point = {
@@ -1483,6 +1442,99 @@ App.InteractiveChartComponent = Ember.Component.extend({
         this._updateHighlightPositions();
 
     }.observes('highlightedItems'),
+    _rangesChanged: function ()
+    {
+        if (!this._currentTimeSeries)
+            return;
+
+        function midPoint(firstPoint, secondPoint) {
+            if (firstPoint && secondPoint)
+                return (+firstPoint.time + +secondPoint.time) / 2;
+            if (firstPoint)
+                return firstPoint.time;
+            return secondPoint.time;
+        }
+        var currentTimeSeries = this._currentTimeSeries;
+        var linkRoute = this.get('rangeRoute');
+        this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
+            var start = currentTimeSeries.findPointByMeasurementId(range.get('startRun'));
+            var end = currentTimeSeries.findPointByMeasurementId(range.get('endRun'));
+            return Ember.Object.create({
+                startTime: midPoint(currentTimeSeries.previousPoint(start), start),
+                endTime: midPoint(end, currentTimeSeries.nextPoint(end)),
+                range: range,
+                left: null,
+                right: null,
+                rowIndex: null,
+                top: null,
+                bottom: null,
+                linkRoute: linkRoute,
+                linkId: range.get('id'),
+            });
+        }));
+
+        this._updateRangeBarRects();
+    }.observes('ranges'),
+    _updateRangeBarRects: function () {
+        var rangeBars = this.get('rangeBars');
+        if (!rangeBars || !rangeBars.length)
+            return;
+
+        var xScale = this._x;
+        var yScale = this._y;
+
+        // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
+        var minWidth = 3;
+        var sortedBars = rangeBars.map(function (bar) {
+            var left = xScale(bar.get('startTime'));
+            var right = xScale(bar.get('endTime'));
+            if (right - left < minWidth) {
+                left -= minWidth / 2;
+                right += minWidth / 2;
+            }
+            bar.set('left', left);
+            bar.set('right', right);
+            return bar;
+        }).sort(function (first, second) { return first.get('left') - second.get('left'); });
+
+        // At this point, left edges of all ranges prior to a range R1 is on the left of R1.
+        // Place R1 into a row in which right edges of all ranges prior to R1 is on the left of R1 to avoid overlapping ranges.
+        var rows = [];
+        sortedBars.forEach(function (bar) {
+            var rowIndex = 0;
+            for (; rowIndex < rows.length; rowIndex++) {
+                var currentRow = rows[rowIndex];
+                if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
+                    currentRow.push(bar);
+                    break;
+                }
+            }
+            if (rowIndex >= rows.length)
+                rows.push([bar]);
+            bar.set('rowIndex', rowIndex);
+        });
+        var rowHeight = 0.6 * this._rem;
+        var firstRowTop = this._contentHeight - rows.length * rowHeight;
+        var barHeight = 0.5 * this._rem;
+
+        $(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
+            left: this._margin.left + 'px',
+            top: this._margin.top + firstRowTop + 'px',
+            width: this._contentWidth + 'px',
+            height: rows.length * barHeight + 'px',
+            overflow: 'hidden',
+            position: 'absolute',
+        });
+
+        var margin = this._margin;
+        sortedBars.forEach(function (bar) {
+            var top = bar.get('rowIndex') * rowHeight;
+            var height = barHeight;
+            var left = bar.get('left');
+            var width = bar.get('right') - left;
+            bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
+        });
+    },
     _updateCurrentItemIndicators: function ()
     {
         if (!this._currentItemLine)
@@ -1548,6 +1600,10 @@ App.InteractiveChartComponent = Ember.Component.extend({
             this.sendAction('zoom', this._currentSelection());
             this.set('selection', null);
         },
+        openRange: function (range)
+        {
+            this.sendAction('openRange', range);
+        },
     },
 });
 
index d99ffc1..f5dc9ed 100755 (executable)
 }
 
 .chart .extent {
-    stroke: #9c6;
+    stroke: #f93;
     stroke-width: 1px;
     fill: #9c6;
     fill-opacity: .125;
     fill: #333;
     stroke: none;
 }
+
+.chart .rangeBar {
+    display: block;
+    background-color: #fc6;
+    position: absolute;
+}
index e50387a..059de42 100755 (executable)
@@ -423,3 +423,10 @@ TimeSeries.prototype.previousPoint = function (point)
         return null;
     return this._series[point.seriesIndex - 1];
 }
+
+TimeSeries.prototype.nextPoint = function (point)
+{
+    if (!point.seriesIndex)
+        return null;
+    return this._series[point.seriesIndex + 1];
+}
index 8cfe0f9..039c76f 100755 (executable)
                     <h1 {{action "toggleDetails"}}>{{metric.fullName}} - {{ platform.name}}</h1>
                     <a href="#" title="Close" class="close-button" {{action "close"}}>{{partial "close-button"}}</a>
                     {{#if App.Manifest.bugTrackers}}
-                        <a href="#" title="Bugs and Analysis" class="bugs-button" {{action "toggleBugsPane"}}>
-                            {{partial "bugs-button"}}
+                        <a href="#" title="Analysis" class="bugs-button" {{action "toggleBugsPane"}}>
+                            {{partial "analysis-button"}}
                         </a>
                     {{/if}}
                     {{#if App.Manifest.repositoriesWithReportedCommits}}
                     {{#if chartData}}
                         {{interactive-chart
                             chartData=chartData
+                            ranges=analyticRanges
                             domain=mainPlotDomain
                             interactive=true
                             chartPointRadius=2
                             currentTime=sharedTime
                             selectedItem=selectedItem
                             highlightedItems=highlightedItems
+                            rangeRoute="analysisTask"
                             selection=timeRange
                             sharedSelection=sharedSelection
                             selectionChanged="rangeChanged"
                     {{input action="searchCommit" placeholder="Name or email" value=commitSearchKeyword}}
                 </form>
 
-                <div {{bind-attr class=":bugs-pane showingBugsPane::hidden"}}>
+                <div {{bind-attr class=":bugs-pane showingAnalysisPane::hidden"}}>
                     <table>
                         <tbody>
-                            {{#if selectedSinglePoint}}
-                                {{#each details.bugTrackers}}
-                                    <tr>
-                                        <th>{{label}}</th>
-                                        <td>
-                                            <form {{action "associateBug" this editedBugNumber on="submit"}}>
-                                                {{input type=text value=editedBugNumber}}
-                                            </form>
-                                        </td>
-                                    </tr>
-                                {{/each}}
-                            {{/if}}
                             <tr>
                                 <th>
                                     <label>Name: {{input type=text value=newAnalysisTaskName}}</label>
-                                    <button {{action "createAnalysisTask"}}>Analyze</button>
+                                    <button {{action "createAnalysisTask"}} {{bind-attr disabled=cannotAnalyze}}>Analyze</button>
                                 </th>
                             </tr>
                         </tbody>
                 </a>
             </div>
         {{/if}}
+        <div class="rangeBarsContainerInlineStyle">
+            {{#each rangeBars}}
+                {{#link-to linkRoute linkId}}
+                    <span class="rangeBar" {{bind-attr style=inlineStyle}}></span>
+                {{/link-to}}
+            {{/each}}
+        </div>
     </script>
 
     <script type="text/x-handlebars" data-template-name="chart-details">
         </svg>
     </script>
 
-    <script type="text/x-handlebars" data-template-name="bugs-button">
-        <svg class="bugs-button icon-button" viewBox="0 0 100 100">
+    <script type="text/x-handlebars" data-template-name="analysis-button">
+        <svg class="analysis-button icon-button" viewBox="0 0 100 100">
             <g stroke="black" stroke-width="15">
                 <circle cx="50" cy="50" r="40" fill="transparent"/>
                 <line x1="50" y1="25" x2="50" y2="55"/>