Perf dashboard should provide a way to associate bugs with a test run
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 22 Oct 2014 00:53:39 +0000 (00:53 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 22 Oct 2014 00:53:39 +0000 (00:53 +0000)
https://bugs.webkit.org/show_bug.cgi?id=137857

Reviewed by Andreas Kling.

Added a "privileged" API, /privileged-api/associate-bug, to associate a bug with a test run.
/privileged-api/ is to be protected by an authentication mechanism such as DigestAuth over https by
the Apache configuration.

The Cross Site Request (CSRF) Forgery prevention for privileged APIs work as follows. When a user is
about to make a privileged API access, the front end code obtains a CSRF token generated by POST'ing
to privileged-api/generate-csrf-token; the page sets a randomly generated salt and an expiration time
via the cookie and returns a token computed from those two values as well as the remote username.

The font end code then POST's the request along with the returned token. The server side code verifies
that the specified token can be generated from the salt and the expiration time set in the cookie, and
the token hasn't expired.

* init-database.sql: Added bug_url to bug_trackers table, and added bugs table. Each bug tracker will
have zero or exactly one bug associated with a test run.

* public/admin/bug-trackers.php: Added the support for editing bug_url.
* public/api/runs.php:
(fetch_runs_for_config): Modified the query to fetch bugs associated with test_runs.
(parse_bugs_array): Added. Parses the aggregated bugs and creates a dictionary that maps a tracker id to
an associated bug if there is one.
(format_run): Calls parse_bugs_array.

* public/include/json-header.php: Added helper functions to deal for CSRF prevention.
(ensure_privileged_api_data): Added. Dies immediately if the request's method is not POST or doesn't
have a valid JSON payload.
(ensure_privileged_api_data_and_token): Ditto. Also checks that the CSRF prevention token is valid.
(compute_token): Computes a CSRF token using the REMOTE_USER (e.g. set via BasicAuth), the salt, and
the expiration time stored in the cookie.
(verify_token): Returns true iff the specified token matches what compute_token returns from the cookie.

* public/include/manifest.php:
(ManifestGenerator::bug_trackers): Include bug_url as bugUrl in the manifest. Also use tracker_id instead
of tracker_name as the key in the manifest. This requires changes to both v1 and v2 front end.

* public/index.html:
(Chart..showTooltipWithResults): Updated for the manifest format changed mentioned above.

* public/privileged-api/associate-bug.php: Added.
(main): Added. Associates or dissociates a bug with a test run inside a transaction. It prevent a CSRF
attack via ensure_privileged_api_data_and_token, which calls verify_token.

* public/privileged-api/generate-csrf-token.php: Added. Generates a CSRF token valid for one hour.

* public/v2/app.css:
(.disabled .icon-button:hover g): Used by the "bugs" icon when a range of points or no points are
selected in a chart.

* public/v2/app.js:
(App.PaneController.actions.toggleBugsPane): Added. Toggles the visibility of the bugs pane when exactly
one point is selected in the chart. Also hides the search pane when making the bugs pane visible since
they would overlap on each other if both of them are shown.
(App.PaneController.actions.associateBug): Makes a privileged API request to associate the specified bug
with the currently selected point (test run). Updates the bug information in "details" and colors of dots
in the charts to reflect new states. Because chart data objects aren't real Ember objects for performance
reasons, we have to use a dirty hack of modifying a dummy counter bugsChangeCount.
(App.PaneController.actions.toggleSearchPane): Renamed from toggleSearch. Also hides the bugs pane when
showing the search pane.
(App.PaneController.actions.rangeChanged): Takes all selected points as the second argument instead of
taking start and end points as the second and the third arguments so that _showDetails can enumerate all
bugs in the selected range.

(App.PaneController._detailsChanged): Added. Hide the bugs pane whenever a new point is selected.
Also update singlySelectedPoint, which is used by toggleBugsPane and associateBug.
(App.PaneController._currentItemChanged): Updated for the _showDetails change.
(App.PaneController._showDetails): Takes an array of selected points in place of old arguments.
Simplified the code to compute the revision information. Calls _updateBugs to format the associated bugs.
(App.PaneController._updateBugs): Sets details.bugTrackers to a dictionary that maps a bug tracker id to
a bug tracker proxy with an array of (bugNumber, bugUrl) pairs and also editedBugNumber, which is used by
the bugs pane to associate or dissociate a bug number, if exactly one point is selected.

(App.InteractiveChartComponent._updateDotsWithBugs): Added. Sets hasBugs class on dots as needed.
(App.InteractiveChartComponent._setCurrentSelection): Finds and passes all points in the selected range
to selectionChanged action instead of just finding the first and the last points.

* public/v2/chart-pane.css: Updated the style.

* public/v2/data.js:
(PrivilegedAPI): Added. A wrapper for privileged APIs' CSRF tokens.
(PrivilegedAPI.sendRequest): Makes a privileged API call. Fetches a new CSRF token if needed.
(PrivilegedAPI._generateTokenInServerIfNeeded): Makes a request to privileged-api/generate-csrf-token if
we haven't already obtained a CSRF token or if the token has already been expired.
(PrivilegedAPI._post): Makes a single POST request to /privileged-api/* with a JSON payload.

(Measurement.prototype.bugs): Added.
(Measurement.prototype.hasBugs): Returns true iff bugs has more than one bug number.
(Measurement.prototype.associateBug): Associates a bug with a test run via privileged-api/associate-bug.

* public/v2/index.html: Added the bugs pane. Also added a list of bugs associated with the current run in
the details.

* public/v2/manifest.js:
(App.BugTracker.bugUrl):
(App.BugTracker.newBugUrl): Added.
(App.BugTracker.repositories): Added. This was a missing back reference to repositories.
(App.MetricSerializer.normalizePayload): Now parses/loads the list of bug trackers from the manifest.
(App.Manifest.repositoriesWithReportedCommits): Now initialized to an empty array instead of null.
(App.Manifest.bugTrackers): Added.
(App.Manifest._fetchedManifest): Sets App.Manifest.bugTrackers. Also sorts the list of repositories by
their respective ids to make the ordering stable.

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

15 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/init-database.sql
Websites/perf.webkit.org/public/admin/bug-trackers.php
Websites/perf.webkit.org/public/api/runs.php
Websites/perf.webkit.org/public/include/json-header.php
Websites/perf.webkit.org/public/include/manifest.php
Websites/perf.webkit.org/public/index.html
Websites/perf.webkit.org/public/privileged-api/associate-bug.php [new file with mode: 0644]
Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php [new file with mode: 0644]
Websites/perf.webkit.org/public/v2/app.css
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
Websites/perf.webkit.org/public/v2/manifest.js

index b9db737..b90e787 100644 (file)
@@ -1,3 +1,113 @@
+2014-10-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Perf dashboard should provide a way to associate bugs with a test run
+        https://bugs.webkit.org/show_bug.cgi?id=137857
+
+        Reviewed by Andreas Kling.
+
+        Added a "privileged" API, /privileged-api/associate-bug, to associate a bug with a test run.
+        /privileged-api/ is to be protected by an authentication mechanism such as DigestAuth over https by
+        the Apache configuration.
+
+
+        The Cross Site Request (CSRF) Forgery prevention for privileged APIs work as follows. When a user is
+        about to make a privileged API access, the front end code obtains a CSRF token generated by POST'ing
+        to privileged-api/generate-csrf-token; the page sets a randomly generated salt and an expiration time
+        via the cookie and returns a token computed from those two values as well as the remote username.
+
+        The font end code then POST's the request along with the returned token. The server side code verifies
+        that the specified token can be generated from the salt and the expiration time set in the cookie, and
+        the token hasn't expired.
+
+
+        * init-database.sql: Added bug_url to bug_trackers table, and added bugs table. Each bug tracker will
+        have zero or exactly one bug associated with a test run.
+
+        * public/admin/bug-trackers.php: Added the support for editing bug_url.
+        * public/api/runs.php:
+        (fetch_runs_for_config): Modified the query to fetch bugs associated with test_runs.
+        (parse_bugs_array): Added. Parses the aggregated bugs and creates a dictionary that maps a tracker id to
+        an associated bug if there is one.
+        (format_run): Calls parse_bugs_array.
+
+        * public/include/json-header.php: Added helper functions to deal for CSRF prevention.
+        (ensure_privileged_api_data): Added. Dies immediately if the request's method is not POST or doesn't
+        have a valid JSON payload.
+        (ensure_privileged_api_data_and_token): Ditto. Also checks that the CSRF prevention token is valid.
+        (compute_token): Computes a CSRF token using the REMOTE_USER (e.g. set via BasicAuth), the salt, and
+        the expiration time stored in the cookie.
+        (verify_token): Returns true iff the specified token matches what compute_token returns from the cookie.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::bug_trackers): Include bug_url as bugUrl in the manifest. Also use tracker_id instead
+        of tracker_name as the key in the manifest. This requires changes to both v1 and v2 front end.
+
+        * public/index.html:
+        (Chart..showTooltipWithResults): Updated for the manifest format changed mentioned above.
+
+        * public/privileged-api/associate-bug.php: Added.
+        (main): Added. Associates or dissociates a bug with a test run inside a transaction. It prevent a CSRF
+        attack via ensure_privileged_api_data_and_token, which calls verify_token.
+
+        * public/privileged-api/generate-csrf-token.php: Added. Generates a CSRF token valid for one hour.
+
+        * public/v2/app.css:
+        (.disabled .icon-button:hover g): Used by the "bugs" icon when a range of points or no points are
+        selected in a chart.
+
+        * public/v2/app.js:
+        (App.PaneController.actions.toggleBugsPane): Added. Toggles the visibility of the bugs pane when exactly
+        one point is selected in the chart. Also hides the search pane when making the bugs pane visible since
+        they would overlap on each other if both of them are shown.
+        (App.PaneController.actions.associateBug): Makes a privileged API request to associate the specified bug
+        with the currently selected point (test run). Updates the bug information in "details" and colors of dots
+        in the charts to reflect new states. Because chart data objects aren't real Ember objects for performance
+        reasons, we have to use a dirty hack of modifying a dummy counter bugsChangeCount.
+        (App.PaneController.actions.toggleSearchPane): Renamed from toggleSearch. Also hides the bugs pane when
+        showing the search pane.
+        (App.PaneController.actions.rangeChanged): Takes all selected points as the second argument instead of
+        taking start and end points as the second and the third arguments so that _showDetails can enumerate all
+        bugs in the selected range.
+
+        (App.PaneController._detailsChanged): Added. Hide the bugs pane whenever a new point is selected.
+        Also update singlySelectedPoint, which is used by toggleBugsPane and associateBug.
+        (App.PaneController._currentItemChanged): Updated for the _showDetails change.
+        (App.PaneController._showDetails): Takes an array of selected points in place of old arguments.
+        Simplified the code to compute the revision information. Calls _updateBugs to format the associated bugs.
+        (App.PaneController._updateBugs): Sets details.bugTrackers to a dictionary that maps a bug tracker id to
+        a bug tracker proxy with an array of (bugNumber, bugUrl) pairs and also editedBugNumber, which is used by
+        the bugs pane to associate or dissociate a bug number, if exactly one point is selected.
+
+        (App.InteractiveChartComponent._updateDotsWithBugs): Added. Sets hasBugs class on dots as needed.
+        (App.InteractiveChartComponent._setCurrentSelection): Finds and passes all points in the selected range
+        to selectionChanged action instead of just finding the first and the last points.
+
+        * public/v2/chart-pane.css: Updated the style.
+
+        * public/v2/data.js:
+        (PrivilegedAPI): Added. A wrapper for privileged APIs' CSRF tokens.
+        (PrivilegedAPI.sendRequest): Makes a privileged API call. Fetches a new CSRF token if needed.
+        (PrivilegedAPI._generateTokenInServerIfNeeded): Makes a request to privileged-api/generate-csrf-token if
+        we haven't already obtained a CSRF token or if the token has already been expired.
+        (PrivilegedAPI._post): Makes a single POST request to /privileged-api/* with a JSON payload.
+
+        (Measurement.prototype.bugs): Added.
+        (Measurement.prototype.hasBugs): Returns true iff bugs has more than one bug number.
+        (Measurement.prototype.associateBug): Associates a bug with a test run via privileged-api/associate-bug.
+
+        * public/v2/index.html: Added the bugs pane. Also added a list of bugs associated with the current run in
+        the details.
+
+        * public/v2/manifest.js:
+        (App.BugTracker.bugUrl):
+        (App.BugTracker.newBugUrl): Added.
+        (App.BugTracker.repositories): Added. This was a missing back reference to repositories.
+        (App.MetricSerializer.normalizePayload): Now parses/loads the list of bug trackers from the manifest.
+        (App.Manifest.repositoriesWithReportedCommits): Now initialized to an empty array instead of null.
+        (App.Manifest.bugTrackers): Added.
+        (App.Manifest._fetchedManifest): Sets App.Manifest.bugTrackers. Also sorts the list of repositories by
+        their respective ids to make the ordering stable.
+
 2014-10-14  Ryosuke Niwa  <rniwa@webkit.org>
 
         Remove unused jobs table
index 743f48d..e527ab0 100644 (file)
@@ -29,6 +29,7 @@ CREATE TABLE repositories (
 CREATE TABLE bug_trackers (
     tracker_id serial PRIMARY KEY,
     tracker_name varchar(64) NOT NULL,
+    tracker_bug_url varchar(1024),
     tracker_new_bug_url varchar(1024));
 
 CREATE TABLE tracker_repositories (
@@ -128,3 +129,12 @@ CREATE TABLE reports (
     report_content text,
     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);
index b25cd63..b704e51 100644 (file)
@@ -11,7 +11,9 @@ if ($db) {
         } else
             notice('Could not add the bug tracker.');
     } else if ($action == 'update') {
-        if (update_field('bug_trackers', 'tracker', 'name') || update_field('bug_trackers', 'tracker', 'new_bug_url'))
+        if (update_field('bug_trackers', 'tracker', 'name')
+            || update_field('bug_trackers', 'tracker', 'bug_url')
+            || update_field('bug_trackers', 'tracker', 'new_bug_url'))
             regenerate_manifest();
         else
             notice('Invalid parameters.');
@@ -63,6 +65,7 @@ END;
 
     $page = new AdministrativePage($db, 'bug_trackers', 'tracker', array(
         'name' => array('editing_mode' => 'string'),
+        'bug_url' => array('editing_mode' => 'url', 'label' => 'Bug URL ($number)'),
         'new_bug_url' => array('editing_mode' => 'text', 'label' => 'New Bug URL ($title, $description)'),
         'Associated repositories' => array('custom' => function ($row) { return associated_repositories($row); }),
     ));
index a059848..ff5766b 100644 (file)
@@ -30,10 +30,14 @@ 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, test_runs
-        WHERE run_build = build_id AND run_config = $1
-        GROUP BY build_id, run_id', array($config['config_id']));
+        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']));
 
     $formatted_runs = array();
     if (!$raw_runs)
@@ -62,6 +66,19 @@ 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']),
@@ -70,6 +87,7 @@ 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 fcd2598..3504319 100644 (file)
@@ -69,4 +69,42 @@ function require_existence_of($array, $list_of_arguments, $prefix = '') {
     }
 }
 
+function ensure_privileged_api_data() {
+    global $HTTP_RAW_POST_DATA;
+
+    if ($_SERVER['REQUEST_METHOD'] != 'POST')
+        exit_with_error('InvalidRequestMethod');
+
+    if (!isset($HTTP_RAW_POST_DATA))
+        exit_with_error('InvalidRequestContent');
+
+    $data = json_decode($HTTP_RAW_POST_DATA, true);
+
+    if ($data === NULL)
+        exit_with_error('InvalidRequestContent');
+
+    return $data;
+}
+
+function ensure_privileged_api_data_and_token() {
+    $data = ensure_privileged_api_data();
+    if (!verify_token(array_get($data, 'token')))
+        exit_with_error('InvalidToken');
+    return $data;
+}
+
+function compute_token() {
+    if (!array_key_exists('CSRFSalt', $_COOKIE) || !array_key_exists('CSRFExpiration', $_COOKIE))
+        return NULL;
+    $user = array_get($_SERVER, 'REMOTE_USER');
+    $salt = $_COOKIE['CSRFSalt'];
+    $expiration = $_COOKIE['CSRFExpiration'];
+    return hash('sha256', "$salt|$user|$expiration");
+}
+
+function verify_token($token) {
+    $expected_token = compute_token();
+    return $expected_token && $token == $expected_token && $_COOKIE['CSRFExpiration'] > time();
+}
+
 ?>
index 269b3a8..4678e95 100644 (file)
@@ -139,7 +139,10 @@ class ManifestGenerator {
         $bug_trackers_table = $this->db->fetch_table('bug_trackers');
         if ($bug_trackers_table) {
             foreach ($bug_trackers_table as $row) {
-                $bug_trackers[$row['tracker_name']] = array('newBugUrl' => $row['tracker_new_bug_url'],
+                $bug_trackers[$row['tracker_id']] = array(
+                    'name' => $row['tracker_name'],
+                    'bugUrl' => $row['tracker_bug_url'],
+                    'newBugUrl' => $row['tracker_new_bug_url'],
                     'repositories' => $tracker_id_to_repositories[$row['tracker_id']]);
             }
         }
index dd8b8ae..b37c271 100644 (file)
@@ -634,8 +634,9 @@ td, th {
                     + ' around ' + result.build().formattedTime();
                 var revisions = result.build().formattedRevisions(resultToCompare.build());
 
-                for (var trackerName in bugTrackers) {
-                    var repositories = bugTrackers[trackerName].repositories;
+                for (var trackerId in bugTrackers) {
+                    var tracker = bugTrackers[trackerId];
+                    var repositories = tracker.repositories;
                     var description = 'Platform: ' + result.build().platform().name + '\n\n';
                     for (var i = 0; i < repositories.length; ++i) {
                         var repositoryName = repositories[i];
@@ -648,13 +649,13 @@ td, th {
                             description += revision.label;
                         description += '\n';
                     }
-                    var url = bugTrackers[trackerName].newBugUrl
+                    var url = tracker.newBugUrl
                         .replace(/\$title/g, encodeURIComponent(title))
                         .replace(/\$description/g, encodeURIComponent(description))
                         .replace(/\$link/g, encodeURIComponent(location.href));
                     if (newBugUrls)
                         newBugUrls += ',';
-                    newBugUrls += ' <a href="' + url + '" target="_blank">' + trackerName + '</a>';
+                    newBugUrls += ' <a href="' + url + '" target="_blank">' + tracker.name + '</a>';
                 }
                 newBugUrls = 'File:' + newBugUrls;
             }
diff --git a/Websites/perf.webkit.org/public/privileged-api/associate-bug.php b/Websites/perf.webkit.org/public/privileged-api/associate-bug.php
new file mode 100644 (file)
index 0000000..6734005
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+require_once('../include/json-header.php');
+
+function main() {
+    $data = ensure_privileged_api_data_and_token();
+
+    $run_id = array_get($data, 'run');
+    $bug_tracker_id = array_get($data, 'tracker');
+    $bug_number = array_get($data, 'bugNumber');
+
+    if (!$run_id)
+        exit_with_error('InvalidRunId', array('run' => $run_id));
+    if (!$bug_tracker_id)
+        exit_with_error('InvalidBugTrackerId', array('tracker' => $bug_tracker_id));
+
+    $db = connect();
+    $db->begin_transaction();
+
+    $bug_id = NULL;
+    if (!$bug_number) {
+        $count = $db->query_and_get_affected_rows("DELETE FROM bugs WHERE bug_run = $1 AND bug_tracker = $2",
+            array($run_id, $bug_tracker_id));
+        if ($count > 1) {
+            $db->rollback_transaction();
+            exit_with_error('UnexpectedNumberOfAffectedRows', array('affectedRows' => $count));
+        }
+    } else {
+        $bug_id = $db->update_or_insert_row('bugs', 'bug', array('run' => $run_id, 'tracker' => $bug_tracker_id),
+            array('run' => $run_id, 'tracker' => $bug_tracker_id, 'number' => $bug_number));
+    }
+    $db->commit_transaction();
+
+    exit_with_success(array('bug_id' => $bug_id));
+}
+
+main();
+
+?>
diff --git a/Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php b/Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php
new file mode 100644 (file)
index 0000000..45d57a5
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+require_once('../include/json-header.php');
+
+ensure_privileged_api_data();
+
+$user = array_get($_SERVER, 'REMOTE_USER');
+
+$expiritaion = time() + 3600; // Valid for one hour.
+$_COOKIE['CSRFSalt'] = rand();
+$_COOKIE['CSRFExpiration'] = $expiritaion;
+
+setcookie('CSRFSalt', $_COOKIE['CSRFSalt']);
+setcookie('CSRFExpiration', $expiritaion);
+
+exit_with_success(array('user' => $user, 'token' => compute_token(), 'expiration' => $expiritaion * 1000));
+
+?>
index a9938e0..bb33db9 100755 (executable)
@@ -125,6 +125,9 @@ body {
 .icon-button:hover g {
     stroke: #666;
 }
+.disabled .icon-button:hover g {
+    stroke: #ccc;
+}
 
 
 #header {
index c34cd56..ee7dd3e 100755 (executable)
@@ -664,6 +664,7 @@ App.PaneController = Ember.ObjectController.extend({
     sharedTime: Ember.computed.alias('parentController.sharedTime'),
     sharedSelection: Ember.computed.alias('parentController.sharedSelection'),
     selection: null,
+    bugsChangeCount: 0, // Dirty hack. Used to call InteractiveChartComponent's _updateDotsWithBugs.
     actions: {
         toggleDetails: function()
         {
@@ -673,14 +674,33 @@ App.PaneController = Ember.ObjectController.extend({
         {
             this.parentController.removePane(this.get('model'));
         },
-        toggleSearch: function ()
+        toggleBugsPane: function ()
+        {
+            if (!App.Manifest.bugTrackers || !this.get('singlySelectedPoint'))
+                return;
+            if (this.toggleProperty('showingBugsPane'))
+                this.set('showingSearchPane', false);
+        },
+        associateBug: function (bugTracker, bugNumber)
+        {
+            var point = this.get('singlySelectedPoint');
+            if (!point)
+                return;
+            var self = this;
+            point.measurement.associateBug(bugTracker.get('id'), bugNumber).then(function () {
+                self._updateBugs();
+                self.set('bugsChangeCount', self.get('bugsChangeCount') + 1);
+            });
+        },
+        toggleSearchPane: function ()
         {
             if (!App.Manifest.repositoriesWithReportedCommits)
                 return;
             var model = this.get('model');
             if (!model.get('commitSearchRepository'))
                 model.set('commitSearchRepository', App.Manifest.repositoriesWithReportedCommits[0]);
-            this.toggleProperty('showingSearchPane');
+            if (this.toggleProperty('showingSearchPane'))
+                this.set('showingBugsPane', false);
         },
         searchCommit: function () {
             var model = this.get('model');
@@ -697,19 +717,24 @@ App.PaneController = Ember.ObjectController.extend({
             this.set('intrinsicDomain', intrinsicDomain);
             this.get('parentController').updateSharedDomain();
         },
-        rangeChanged: function (extent, startPoint, endPoint)
+        rangeChanged: function (extent, points)
         {
-            if (!startPoint || !endPoint) {
+            if (!points) {
                 this._hasRange = false;
                 this.set('details', null);
                 this.set('timeRange', null);
                 return;
             }
             this._hasRange = true;
-            this._showDetails(startPoint.measurement, endPoint.measurement, false);
+            this._showDetails(points);
             this.set('timeRange', extent);
         },
     },
+    _detailsChanged: function ()
+    {
+        this.set('showingBugsPane', false);
+        this.set('singlySelectedPoint', !this._hasRange && this._selectedPoints ? this._selectedPoints[0] : null);
+    }.observes('details'),
     _overviewSelectionChanged: function ()
     {
         var overviewSelection = this.get('overviewSelection');
@@ -745,29 +770,25 @@ App.PaneController = Ember.ObjectController.extend({
         if (!point || !point.measurement)
             this.set('details', null);
         else
-            this._showDetails(point.series.previousPoint(point).measurement, point.measurement, true);
+            this._showDetails([point]);
     }.observes('currentItem'),
-    _showDetails: function (oldMeasurement, currentMeasurement, isShowingEndPoint)
+    _showDetails: function (points)
     {
-        var revisions = [];
-
+        var isShowingEndPoint = !this._hasRange;
+        var currentMeasurement = points[0].measurement;
+        var oldMeasurement = points[points.length - 1].measurement;
         var formattedRevisions = currentMeasurement.formattedRevisions(oldMeasurement);
-        var repositoryNames = [];
-        for (var repositoryName in formattedRevisions)
-            repositoryNames.push(repositoryName);
-        var revisions = [];
-        repositoryNames.sort().forEach(function (repositoryName) {
-            var revision = formattedRevisions[repositoryName];
-            var repository = App.Manifest.repository(repositoryName);
-            revision['url'] = false;
-            if (repository) {
-                revision['url'] = revision.previousRevision
-                    ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
-                    : repository.urlForRevision(revision.currentRevision);
-            }
+        var revisions = App.Manifest.get('repositories')
+            .filter(function (repository) { return formattedRevisions[repository.get('id')]; })
+            .map(function (repository) {
+            var repositoryName = repository.get('id');
+            var revision = Ember.Object.create(formattedRevisions[repositoryName]);
+            revision['url'] = revision.previousRevision
+                ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
+                : repository.urlForRevision(revision.currentRevision);
             revision['name'] = repositoryName;
             revision['repository'] = repository;
-            revisions.push(Ember.Object.create(revision));            
+            return revision; 
         });
 
         var buildNumber = null;
@@ -778,14 +799,48 @@ App.PaneController = Ember.ObjectController.extend({
             if (builder)
                 buildURL = builder.urlFromBuildNumber(buildNumber);
         }
-        this.set('details', {
+
+        this._selectedPoints = points;
+        this.set('details', Ember.Object.create({
             currentValue: currentMeasurement.mean().toFixed(2),
             oldValue: oldMeasurement && !isShowingEndPoint ? oldMeasurement.mean().toFixed(2) : null,
             buildNumber: buildNumber,
             buildURL: buildURL,
             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(); });
+        this._selectedPoints.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.
+        }));
     }
 });
 
@@ -1063,6 +1118,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
                 .attr("cx", function(measurement) { return xScale(measurement.time); })
                 .attr("cy", function(measurement) { return yScale(measurement.value); });
         });
+        this._updateDotsWithBugs();
         this._updateHighlightPositions();
 
         if (this._brush) {
@@ -1091,6 +1147,13 @@ App.InteractiveChartComponent = Ember.Component.extend({
             .style("z-index", "100")
             .text(this._yAxisUnit);
     },
+    _updateDotsWithBugs: function () {
+        if (!this.get('interactive'))
+            return;
+        this._dots.forEach(function (dot) {
+            dot.classed('hasBugs', function (point) { return !!point.measurement.hasBugs(); });
+        })
+    }.observes('bugsChangeCount'), // Never used for anything but to call this method :(
     _updateHighlightPositions: function () {
         var xScale = this._x;
         var yScale = this._y;
@@ -1424,22 +1487,12 @@ App.InteractiveChartComponent = Ember.Component.extend({
         if (this._brushExtent === newSelection)
             return;
 
+        var points = null;
         if (newSelection) {
-            var startPoint;
-            var endPoint;
-            for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
-                var point = this._currentTimeSeriesData[i];
-                if (!startPoint) {
-                    if (point.time >= newSelection[0]) {
-                        if (point.time > newSelection[1])
-                            break;
-                        startPoint = point;
-                    }
-                } else if (point.time > newSelection[1])
-                    break;
-                if (point.time >= newSelection[0] && point.time <= newSelection[1])
-                    endPoint = point;
-            }
+            points = this._currentTimeSeriesData
+                .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
+            if (!points.length)
+                points = null;
         }
 
         this._brushExtent = newSelection;
@@ -1447,7 +1500,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
         this._updateSelectionToolbar();
 
         this.set('sharedSelection', newSelection);
-        this.sendAction('selectionChanged', newSelection, startPoint, endPoint);
+        this.sendAction('selectionChanged', newSelection, points);
     },
     _updateSelectionToolbar: function ()
     {
index 39f0e59..6384389 100755 (executable)
     top: 0.55rem;
 }
 
+.chart-pane a.bugs-button {
+    display: inline-block;
+    position: absolute;
+    right: 1.85rem;
+    top: 0.55rem;
+}
+
 .chart-pane a.search-button {
     display: inline-block;
     position: absolute;
@@ -57,9 +64,8 @@
     top: 0.55rem;
 }
 
-.search-pane {
+.search-pane, .bugs-pane {
     position: absolute;
-    right: 0rem;
     top: 1.7rem;
     border: 1px solid #bbb;
     padding: 0;
     background: white;
 }
 
+.bugs-pane {
+    right: 1.3rem;
+}
+
+.bugs-pane table {
+    margin: 0.2rem;
+    font-size: 0.8rem;
+}
+
+.bugs-pane th {
+    font-weight: normal;
+}
+
+.search-pane {
+    right: 0rem;
+}
+
+.bugs-pane.hidden,
 .search-pane.hidden {
     display: none;
 }
 }
 
 .chart-pane .details-table th {
-    width: 4rem;
+    width: 7rem;
     text-align: right;
     font-weight: normal;
     padding: 0;
 }
 
+.chart-pane .details-table .bugs th {
+    font-weight: bold;
+}
+
 .chart-pane .details-table th:after {
     content: " : ";
 }
     stroke: none;
 }
 
+.chart .hasBugs {
+    fill: #33f;
+}
+
 .chart path.area {
     stroke: none;
     fill: #ccc;
index b95dc64..68317ce 100755 (executable)
@@ -1,5 +1,55 @@
 // We don't use DS.Model for these object types because we can't afford to process millions of them.
 
+var PrivilegedAPI = {
+    _token: null,
+    _expiration: null,
+    _maxNetworkLatency: 3 * 60 * 1000 /* 3 minutes */,
+};
+
+PrivilegedAPI.sendRequest = function (url, parameters)
+{
+    return this._generateTokenInServerIfNeeded().then(function (token) {
+        return PrivilegedAPI._post(url, $.extend({token: token}, parameters));
+    });
+}
+
+PrivilegedAPI._generateTokenInServerIfNeeded = function ()
+{
+    var self = this;
+    return new Ember.RSVP.Promise(function (resolve, reject) {
+        if (self._token && self._expiration > Date.now() + self._maxNetworkLatency)
+            resolve(self._token);
+
+        PrivilegedAPI._post('generate-csrf-token')
+            .then(function (result, reject) {
+                self._token = result['token'];
+                self._expiration = new Date(result['expiration']);
+                resolve(self._token);
+            }).catch(reject);
+    });
+}
+
+PrivilegedAPI._post = function (url, parameters)
+{
+    return new Ember.RSVP.Promise(function (resolve, reject) {
+        $.ajax({
+            url: '../privileged-api/' + url,
+            type: 'POST',
+            contentType: 'application/json',
+            data: parameters ? JSON.stringify(parameters) : '{}',
+            dataType: 'json',
+        }).done(function (data) {
+            if (data.status != 'OK')
+                reject(data.status);
+            else
+                resolve(data);
+        }).fail(function (xhr, status, error) {
+            console.log(xhr);
+            reject(xhr.status + (error ? ', ' + error : ''));
+        });
+    });
+}
+
 var CommitLogs = {
     _cachedCommitsByRepository: {}
 };
@@ -220,6 +270,34 @@ Measurement._formatDate = function (date)
     return date.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
 }
 
+Measurement.prototype.bugs = function ()
+{
+    return this._raw['bugs'];
+}
+
+Measurement.prototype.hasBugs = function ()
+{
+    var bugs = this.bugs();
+    return bugs && Object.keys(bugs).length;
+}
+
+Measurement.prototype.associateBug = function (trackerId, bugNumber)
+{
+    var bugs = this._raw['bugs'];
+    trackerId = parseInt(trackerId);
+    bugNumber = bugNumber ? parseInt(bugNumber) : null;
+    return PrivilegedAPI.sendRequest('associate-bug', {
+        run: this.id(),
+        tracker: trackerId,
+        bugNumber: bugNumber,
+    }).then(function () {
+        if (bugNumber)
+            bugs[trackerId] = bugNumber;
+        else
+            delete bugs[trackerId];
+    });
+}
+
 function RunsData(rawData)
 {
     this._measurements = rawData.map(function (run) { return new Measurement(run); });
index b1c14e1..934ba8d 100755 (executable)
                     {{metric.label}}
                     - {{ platform.name}}</h2>
                     <a href="#" title="Close" class="close-button" {{action "close"}}>{{partial "close-button"}}</a>
-                    {{if App.Manifest.repositoriesWithReportedCommits}}
-                        <a href="#" title="Search" class="search-button" {{action "toggleSearch"}}>{{partial "search-button"}}</a>
+                    {{#if App.Manifest.bugTrackers}}
+                        <a href="#" title="Bugs"
+                            {{bind-attr class=":bugs-button singlySelectedPoint::disabled"}}
+                            {{action "toggleBugsPane"}}>
+                            {{partial "bugs-button"}}
+                        </a>
+                    {{/if}}
+                    {{#if App.Manifest.repositoriesWithReportedCommits}}
+                        <a href="#" title="Search" class="search-button" {{action "toggleSearchPane"}}>{{partial "search-button"}}</a>
                     {{/if}}
                 </header>
 
                             sharedSelection=sharedSelection
                             selectionChanged="rangeChanged"
                             selectionIsLocked=timeRangeIsLocked
+                            bugsChangeCount=bugsChangeCount
                             zoom="zoomed"}}
                     {{else}}
                         {{#if failure}}
                     {{input action="searchCommit" placeholder="Name or email" value=commitSearchKeyword}}
                 </form>
 
+                <div {{bind-attr class=":bugs-pane showingBugsPane::hidden"}}>
+                    <table>
+                        {{#each details.bugTrackers}}
+                            <tr>
+                                <th>{{label}}</th>
+                                <td>
+                                    <form {{action "associateBug" this editedBugNumber on="submit"}}>
+                                        {{input type=text value=editedBugNumber}}
+                                    </form>
+                                </td>
+                            </tr>
+                        {{/each}}
+                    </table>
+                </div>
+
             </section>
         {{/each}}
     </script>
     <script type="text/x-handlebars" data-template-name="chart-details">
     <div class="details-table-container">
         <table class="details-table">
+            <tbody class="bugs">
+            {{#each details.bugTrackers}}
+                {{#if bugs}}
+                    <tr>
+                        <th>{{label}}</th>
+                        <td>
+                            {{#each bugs}}
+                                <a {{bind-attr href=bugUrl}} target="_blank">{{bugNumber}}</a>
+                            {{/each}}
+                        </td>
+                    </tr>
+                {{/if}}
+            {{/each}}
+            </tbody>
             <tr><th>Current</th><td>{{details.currentValue}} {{chartData.unit}}
             {{#if details.oldValue}}
                 (from {{details.oldValue}})
         </svg>
     </script>
 
+    <script type="text/x-handlebars" data-template-name="bugs-button">
+        <svg class="bugs-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"/>
+                <circle cx="50" cy="67.5" r="2.5" fill="transparent"/>
+            </g>
+        </svg>
+    </script>
+
     <script type="text/x-handlebars" data-template-name="search-button">
         <svg class="search-button icon-button" viewBox="0 0 100 100">
             <g stroke="black" stroke-width="15">
index 87284b0..f579d10 100755 (executable)
@@ -42,7 +42,9 @@ App.Builder = App.NameLabelModel.extend({
 });
 
 App.BugTracker = App.NameLabelModel.extend({
-    buildUrl: DS.attr('string'),
+    bugUrl: DS.attr('string'),
+    newBugUrl: DS.attr('string'),
+    repositories: DS.hasMany('repository'),
 });
 
 App.Platform = App.NameLabelModel.extend({
@@ -93,6 +95,7 @@ App.MetricSerializer = App.PlatformSerializer = DS.RESTSerializer.extend({
             }),
             metrics: this._normalizeIdMap(payload['metrics']),
             repositories: this._normalizeIdMap(payload['repositories']),
+            bugTrackers: this._normalizeIdMap(payload['bugTrackers']),
         };
 
         for (var testId in payload['tests']) {
@@ -144,11 +147,12 @@ App.MetricAdapter = DS.RESTAdapter.extend({
 App.Manifest = Ember.Controller.extend({
     platforms: null,
     topLevelTests: null,
+    repositories: [],
+    repositoriesWithReportedCommits: [],
+    bugTrackers: [],
     _platformById: {},
     _metricById: {},
     _builderById: {},
-    repositories: null,
-    repositoriesWithReportedCommits: null,
     _repositoryById: {},
     _fetchPromise: null,
     fetch: function ()
@@ -196,8 +200,10 @@ App.Manifest = Ember.Controller.extend({
         repositories.forEach(function (repository) {
             self._repositoryById[repository.get('id')] = repository;
         });
-        this.set('repositories', repositories);
+        this.set('repositories', repositories.sortBy('id'));
         this.set('repositoriesWithReportedCommits',
             repositories.filter(function (repository) { return repository.get('hasReportedCommits'); }));
+
+        this.set('bugTrackers', store.all('bugTracker').sortBy('name'));
     }
 }).create();