New perf dashboard should have multiple dashboard pages
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 6 Feb 2015 22:43:08 +0000 (22:43 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 6 Feb 2015 22:43:08 +0000 (22:43 +0000)
https://bugs.webkit.org/show_bug.cgi?id=141339

Reviewed by Chris Dumez.

Added the support for multiple dashboard pages. Also added the status of the latest data point.
e.g. "5% better than target"

* public/v2/app.css: Tweaked the styles to work around the fact Ember.js creates empty script elements.
Also hid the border lines around charts on the dashboard page for a cleaner look.

* public/v2/app.js:
(App.IndexRoute): Added. Navigate to /dashboard/<defaultDashboardName> once the manifest.json is loaded.
(App.IndexRoute.beforeModel): Added.
(App.DashboardRoute): Added.
(App.DashboardRoute.model): Added. Return the dashboard specified by the name.
(App.CustomDashboardRoute): Added. This route is used for a customized dashboard specified by "grid".
(App.CustomDashboardRoute.model): Create a dashboard model from "grid" query parameter.
(App.CustomDashboardRoute.renderTemplate): Use the dashboard template.
(App.DashboardController): Renamed from App.IndexController.
(App.DashboardController.modelChanged): Renamed from gridChanged. Removed the code to deal with "grid"
and "defaultDashboard" as these are taken care of by newly added routers.
(App.DashboardController.computeGrid): Renamed from updateGrid. No longer updates "grid" since this is
now done in actions.toggleEditMode.
(App.DashboardController.actions.toggleEditMode): Navigate to CustomDashboardRoute when start editing
an existing dashboard.

(App.Pane.computeStatus): Moved from App.PaneController so that to be called in App.Pane.latestStatus.
Also moved the code to compute the delta with respect to the previous data point from _updateDetails.
(App.Pane._relativeDifferentToLaterPointInTimeSeries): Ditto.
(App.Pane.latestStatus): Added. Used by the dashboard template to show the status of the latest result.

(App.createChartData): Added deltaFormatter to show less significant digits for differences.

(App.PaneController._updateDetails): Updated per changes to computeStatus.

* public/v2/chart-pane.css: Added style rules for the status labels on the dashboard.

* public/v2/data.js:
(TimeSeries.prototype.lastPoint): Added.

* public/v2/index.html: Prefetch manifest.json as soon as possible, show the latest data points' status
on the dashboard, and enumerate all predefined dashboards.

* public/v2/interactive-chart.js:
(App.InteractiveChartComponent._relayoutDataAndAxes): Slightly adjust the offset at which we show unit
for the dashboard page.

* public/v2/manifest.js:
(App.Dashboard): Inherit from App.NameLabelModel now that each predefined dashboard has a name.
(App.MetricSerializer.normalizePayload): Parse all predefined dashboards instead of a single dashboard.
IDs are generated for each dashboard for forward compatibility.
(App.Manifest):
(App.Manifest.dashboardByName): Added.
(App.Manifest.defaultDashboardName): Added.
(App.Manifest._fetchedManifest): Create dashboard model objects for all predefined ones.

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

Websites/perf.webkit.org/ChangeLog
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/interactive-chart.js
Websites/perf.webkit.org/public/v2/manifest.js

index eb9a7d2..9ec70a3 100644 (file)
@@ -1,3 +1,62 @@
+2015-02-06  Ryosuke Niwa  <rniwa@webkit.org>
+
+        New perf dashboard should have multiple dashboard pages
+        https://bugs.webkit.org/show_bug.cgi?id=141339
+
+        Reviewed by Chris Dumez.
+
+        Added the support for multiple dashboard pages. Also added the status of the latest data point.
+        e.g. "5% better than target"
+
+        * public/v2/app.css: Tweaked the styles to work around the fact Ember.js creates empty script elements.
+        Also hid the border lines around charts on the dashboard page for a cleaner look.
+
+        * public/v2/app.js:
+        (App.IndexRoute): Added. Navigate to /dashboard/<defaultDashboardName> once the manifest.json is loaded.
+        (App.IndexRoute.beforeModel): Added.
+        (App.DashboardRoute): Added.
+        (App.DashboardRoute.model): Added. Return the dashboard specified by the name.
+        (App.CustomDashboardRoute): Added. This route is used for a customized dashboard specified by "grid".
+        (App.CustomDashboardRoute.model): Create a dashboard model from "grid" query parameter.
+        (App.CustomDashboardRoute.renderTemplate): Use the dashboard template.
+        (App.DashboardController): Renamed from App.IndexController.
+        (App.DashboardController.modelChanged): Renamed from gridChanged. Removed the code to deal with "grid"
+        and "defaultDashboard" as these are taken care of by newly added routers.
+        (App.DashboardController.computeGrid): Renamed from updateGrid. No longer updates "grid" since this is
+        now done in actions.toggleEditMode.
+        (App.DashboardController.actions.toggleEditMode): Navigate to CustomDashboardRoute when start editing
+        an existing dashboard.
+
+        (App.Pane.computeStatus): Moved from App.PaneController so that to be called in App.Pane.latestStatus.
+        Also moved the code to compute the delta with respect to the previous data point from _updateDetails.
+        (App.Pane._relativeDifferentToLaterPointInTimeSeries): Ditto.
+        (App.Pane.latestStatus): Added. Used by the dashboard template to show the status of the latest result.
+
+        (App.createChartData): Added deltaFormatter to show less significant digits for differences.
+
+        (App.PaneController._updateDetails): Updated per changes to computeStatus.
+
+        * public/v2/chart-pane.css: Added style rules for the status labels on the dashboard.
+
+        * public/v2/data.js:
+        (TimeSeries.prototype.lastPoint): Added.
+
+        * public/v2/index.html: Prefetch manifest.json as soon as possible, show the latest data points' status
+        on the dashboard, and enumerate all predefined dashboards.
+
+        * public/v2/interactive-chart.js:
+        (App.InteractiveChartComponent._relayoutDataAndAxes): Slightly adjust the offset at which we show unit
+        for the dashboard page.
+
+        * public/v2/manifest.js:
+        (App.Dashboard): Inherit from App.NameLabelModel now that each predefined dashboard has a name.
+        (App.MetricSerializer.normalizePayload): Parse all predefined dashboards instead of a single dashboard.
+        IDs are generated for each dashboard for forward compatibility.
+        (App.Manifest):
+        (App.Manifest.dashboardByName): Added.
+        (App.Manifest.defaultDashboardName): Added.
+        (App.Manifest._fetchedManifest): Create dashboard model objects for all predefined ones.
+
 2015-02-05  Ryosuke Niwa  <rniwa@webkit.org>
 
         Move commits viewer to the end of details view
index 51cb5c8..6cdf4cf 100755 (executable)
@@ -201,12 +201,12 @@ body {
     margin: 0;
     line-height: 1rem;
 }
-#navigation li:not(:last-child) a {
+#navigation li:not(:last-of-type) a {
     border-top-right-radius: 0;
     border-bottom-right-radius: 0;
     border-right: 0;
 }
-#navigation li:not(:first-child) a {
+#navigation li:not(:first-of-type) a {
     border-top-left-radius: 0;
     border-bottom-left-radius: 0;
 }
@@ -397,7 +397,7 @@ table.dashboard tbody td a.reset svg {
 }
 
 table.dashboard tbody td .chart {
-    border: solid 1px #ddd;
+    border: solid 0px #ddd;
     border-radius: 0.5rem;
     margin: 0.5rem 0.5rem;
 }
index ec459e0..e69ca6b 100755 (executable)
@@ -1,6 +1,8 @@
 window.App = Ember.Application.create();
 
 App.Router.map(function () {
+    this.resource('customDashboard', {path: 'dashboard/custom'});
+    this.resource('dashboard', {path: 'dashboard/:name'});
     this.resource('charts', {path: 'charts'});
     this.resource('analysis', {path: 'analysis'});
     this.resource('analysisTask', {path: 'analysis/task/:taskId'});
@@ -54,28 +56,47 @@ App.DashboardPaneProxyForPicker = Ember.ObjectProxy.extend({
     }.property('platformId', 'metricId'),
 });
 
-App.IndexController = Ember.Controller.extend({
+App.IndexRoute = Ember.Route.extend({
+    beforeModel: function ()
+    {
+        var self = this;
+        App.Manifest.fetch(this.store).then(function () {
+            self.transitionTo('dashboard', App.Manifest.defaultDashboardName());
+        });
+    },
+});
+
+App.DashboardRoute = Ember.Route.extend({
+    model: function (param)
+    {
+        return App.Manifest.fetch(this.store).then(function () {
+            return App.Manifest.dashboardByName(param.name);
+        });
+    },
+});
+
+App.CustomDashboardRoute = Ember.Route.extend({
+    controllerName: 'dashboard',
+    model: function (param)
+    {
+        return this.store.createRecord('dashboard', {serialized: param.grid});
+    },
+    renderTemplate: function()
+    {
+        this.render('dashboard');
+    }
+});
+
+App.DashboardController = Ember.Controller.extend({
     queryParams: ['grid', 'numberOfDays'],
-    _previousGrid: {},
     headerColumns: [],
     rows: [],
     numberOfDays: 7,
     editMode: false,
 
-    gridChanged: function ()
+    modelChanged: function ()
     {
-        var grid = this.get('grid');
-        if (grid === this._previousGrid)
-            return;
-
-        var dashboard = null;
-        if (grid) {
-            dashboard = this.store.createRecord('dashboard', {serialized: grid});
-            if (!dashboard.get('headerColumns').length)
-                dashboard = null;
-        }
-        if (!dashboard)
-            dashboard = App.Manifest.get('defaultDashboard');
+        var dashboard = this.get('model');
         if (!dashboard)
             return;
 
@@ -95,9 +116,9 @@ App.IndexController = Ember.Controller.extend({
         }));
 
         this.set('emptyRow', new Array(columnCount));
-    }.observes('grid', 'App.Manifest.defaultDashboard').on('init'),
+    }.observes('model').on('init'),
 
-    updateGrid: function()
+    computeGrid: function()
     {
         var headers = this.get('headerColumns').map(function (header) { return header.label; });
         var table = [headers].concat(this.get('rows').map(function (row) {
@@ -106,8 +127,7 @@ App.IndexController = Ember.Controller.extend({
                 return platformAndMetric[0] || platformAndMetric[1] ? platformAndMetric : [];
             }));
         }));
-        this._previousGrid = JSON.stringify(table);
-        this.set('grid', this._previousGrid);
+        return JSON.stringify(table);
     },
 
     _sharedDomainChanged: function ()
@@ -173,8 +193,10 @@ App.IndexController = Ember.Controller.extend({
         toggleEditMode: function ()
         {
             this.toggleProperty('editMode');
-            if (!this.get('editMode'))
-                this.updateGrid();
+            if (this.get('editMode'))
+                this.transitionToRoute('dashboard', 'custom', {name: null, queryParams: {grid: this.computeGrid()}});
+            else
+                this.set('grid', this.computeGrid());
         },
     },
 
@@ -362,7 +384,56 @@ App.Pane = Ember.Object.extend({
         if (typeof(id) == "string")
             return !!id.match(/^[A-Za-z0-9_]+$/);
         return false;
-    }
+    },
+    computeStatus: function (currentPoint, previousPoint)
+    {
+        var chartData = this.get('chartData');
+        var diffFromBaseline = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.baseline);
+        var diffFromTarget = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.target);
+
+        var label = '';
+        var className = '';
+        var formatter = d3.format('.3p');
+
+        var smallerIsBetter = chartData.smallerIsBetter;
+        if (diffFromBaseline !== undefined && diffFromBaseline > 0 == smallerIsBetter) {
+            label = formatter(Math.abs(diffFromBaseline)) + ' ' + (smallerIsBetter ? 'above' : 'below') + ' baseline';
+            className = 'worse';
+        } else if (diffFromTarget !== undefined && diffFromTarget < 0 == smallerIsBetter) {
+            label = formatter(Math.abs(diffFromTarget)) + ' ' + (smallerIsBetter ? 'below' : 'above') + ' target';
+            className = 'better';
+        } else if (diffFromTarget !== undefined)
+            label = formatter(Math.abs(diffFromTarget)) + ' until target';
+
+        var valueDelta = previousPoint ? chartData.deltaFormatter(currentPoint.value - previousPoint.value) : null;
+        if (valueDelta && valueDelta > 0)
+            valueDelta = '+' + valueDelta;
+
+        return {className: className, label: label, currentValue: chartData.formatter(currentPoint.value), valueDelta: valueDelta};
+    },
+    _relativeDifferentToLaterPointInTimeSeries: function (currentPoint, timeSeries)
+    {
+        if (!currentPoint || !timeSeries)
+            return undefined;
+
+        var referencePoint = timeSeries.findPointAfterTime(currentPoint.time);
+        if (!referencePoint)
+            return undefined;
+
+        return (currentPoint.value - referencePoint.value) / referencePoint.value;
+    },
+    latestStatus: function ()
+    {
+        var chartData = this.get('chartData');
+        if (!chartData || !chartData.current)
+            return null;
+
+        var lastPoint = chartData.current.lastPoint();
+        if (!lastPoint)
+            return null;
+
+        return this.computeStatus(lastPoint, chartData.current.previousPoint(lastPoint));
+    }.property('chartData'),
 });
 
 App.createChartData = function (data)
@@ -374,6 +445,7 @@ App.createChartData = function (data)
         target: runs.target ? runs.target.timeSeriesByCommitTime() : null,
         unit: data.unit,
         formatter: data.useSI ? d3.format('.4s') : d3.format('.4g'),
+        deltaFormatter: data.useSI ? d3.format('.2s') : d3.format('.2g'),
         smallerIsBetter: data.smallerIsBetter,
     };
 }
@@ -730,15 +802,15 @@ App.PaneController = Ember.ObjectController.extend({
         }
 
         var currentMeasurement;
-        var oldMeasurement;
+        var previousPoint;
         if (currentPoint) {
             currentMeasurement = currentPoint.measurement;
-            var previousPoint = currentPoint.series.previousPoint(currentPoint);
-            oldMeasurement = previousPoint ? previousPoint.measurement : null;
+            previousPoint = currentPoint.series.previousPoint(currentPoint);
         } else {
             currentMeasurement = selectedPoints[selectedPoints.length - 1].measurement;
-            oldMeasurement = selectedPoints[0].measurement;            
+            previousPoint = selectedPoints[0];
         }
+        var oldMeasurement = previousPoint ? previousPoint.measurement : null;
 
         var formattedRevisions = currentMeasurement.formattedRevisions(oldMeasurement);
         var revisions = App.Manifest.get('repositories')
@@ -762,15 +834,8 @@ App.PaneController = Ember.ObjectController.extend({
                 buildURL = builder.urlFromBuildNumber(buildNumber);
         }
 
-        var chartData = this.get('chartData');
-        var valueDiff = oldMeasurement ? chartData.formatter(currentMeasurement.mean() - oldMeasurement.mean()) : null;
-        if (valueDiff && valueDiff > 0)
-            valueDiff = '+' + valueDiff;
-
         this.set('details', Ember.Object.create({
-            status: this._computeStatus(currentPoint),
-            currentValue: chartData.formatter(currentMeasurement.mean()),
-            valueDiff: valueDiff,
+            status: this.get('model').computeStatus(currentPoint, previousPoint),
             buildNumber: buildNumber,
             buildURL: buildURL,
             buildTime: currentMeasurement.formattedBuildTime(),
@@ -783,40 +848,6 @@ App.PaneController = Ember.ObjectController.extend({
         var points = this.get('selectedPoints');
         this.set('cannotAnalyze', !this.get('newAnalysisTaskName') || !points || points.length < 2);
     }.observes('newAnalysisTaskName'),
-    _computeStatus: function (currentPoint)
-    {
-        var chartData = this.get('chartData');
-
-        var diffFromBaseline = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.baseline);
-        var diffFromTarget = this._relativeDifferentToLaterPointInTimeSeries(currentPoint, chartData.target);
-
-        var label = '';
-        var className = '';
-        var formatter = d3.format('.3p');
-
-        var smallerIsBetter = chartData.smallerIsBetter;
-        if (diffFromBaseline !== undefined && diffFromBaseline > 0 == smallerIsBetter) {
-            label = formatter(Math.abs(diffFromBaseline)) + ' ' + (smallerIsBetter ? 'above' : 'below') + ' baseline';
-            className = 'worse';
-        } else if (diffFromTarget !== undefined && diffFromTarget < 0 == smallerIsBetter) {
-            label = formatter(Math.abs(diffFromTarget)) + ' ' + (smallerIsBetter ? 'below' : 'above') + ' target';
-            className = 'better';
-        } else if (diffFromTarget !== undefined)
-            label = formatter(Math.abs(diffFromTarget)) + ' until target';
-
-        return {className: className, label: label};
-    },
-    _relativeDifferentToLaterPointInTimeSeries: function (currentPoint, timeSeries)
-    {
-        if (!currentPoint || !timeSeries)
-            return undefined;
-        
-        var referencePoint = timeSeries.findPointAfterTime(currentPoint.time);
-        if (!referencePoint)
-            return undefined;
-
-        return (currentPoint.value - referencePoint.value) / referencePoint.value;
-    }
 });
 
 
index 314900d..a052de3 100755 (executable)
 .chart path.baseline {
     stroke: #f66;
 }
-.chart-pane .status .worse {
+.chart-pane .status .worse,
+.dashboard-status .worse {
     color: #c33;
 }
 
 .chart path.target {
     stroke: #66f;
 }
-.chart-pane .status .better {
+.chart-pane .status .better,
+.dashboard-status .better {
     color: #33c;
 }
 
+.dashboard-status .status-label {
+    margin-left: 1rem;
+}
+
 .chart .axis,
 .chart .domain {
   fill: none;
index 3338212..dfe3133 100755 (executable)
@@ -405,6 +405,13 @@ TimeSeries.prototype.minMaxForTimeRange = function (startTime, endTime)
 
 TimeSeries.prototype.series = function () { return this._series; }
 
+TimeSeries.prototype.lastPoint = function ()
+{
+    if (!this._series || !this._series.length)
+        return null;
+    return this._series[this._series.length - 1];
+}
+
 TimeSeries.prototype.previousPoint = function (point)
 {
     if (!point.seriesIndex)
index 6daddce..50b8740 100755 (executable)
@@ -3,6 +3,14 @@
 <head>
     <meta charset="utf-8">
     <title>WebKit Performance Monitor (Beta)</title>
+
+    <link rel="prefetch" href="../data/manifest.json">
+    <script type="application/json" src="../data/manifest.json"></script>
+
+    <link rel="stylesheet" href="app.css">
+    <link rel="stylesheet" href="chart-pane.css">
+
+    <script src="js/jquery.min.js" defer></script>
     <script src="js/jquery.min.js" defer></script>
     <script src="js/handlebars.js" defer></script>
     <script src="js/ember.js" defer></script>
     <script src="popup.js" defer></script>
     <script src="interactive-chart.js" defer></script>
     <script src="commits-viewer.js" defer></script>
-    <link rel="stylesheet" href="app.css">
-    <link rel="stylesheet" href="chart-pane.css">
 
-    <script type="text/x-handlebars" data-template-name="index">
+    <script type="text/x-handlebars" data-template-name="dashboard">
         <header id="header">
             {{partial "navbar"}}
             {{view App.NumberOfDaysControlView tagName="ul" numberOfDays=numberOfDays}}
                                 {{/if}}
                             {{else}}
                                 {{#if chartData}}
+                                    <div class="dashboard-status">
+                                        {{#if latestStatus}}
+                                            {{latestStatus.currentValue}} {{chartData.unit}}
+                                            {{#if latestStatus.label}}
+                                                <span {{bind-attr class=":status-label latestStatus.className"}}>{{latestStatus.label}}</span>
+                                            {{/if}}
+                                        {{/if}}
+                                    </div>
                                     {{#link-to 'charts' (query-params paneList=paneList since=controller.since)}}
-                                    {{interactive-chart
-                                        chartData=chartData
-                                        domain=controller.sharedDomain
-                                        enableSelection=false}}
+                                        {{interactive-chart
+                                            chartData=chartData
+                                            domain=controller.sharedDomain
+                                            enableSelection=false}}
                                     {{/link-to}}
                                 {{else}}
                                     {{#if failure}}
                 <tr>
                     <th>Current</th>
                     <td>
-                        {{details.currentValue}} {{chartData.unit}}
-                        {{#if details.valueDiff}}
-                            ({{details.valueDiff}} {{chartData.unit}})
+                        {{details.status.currentValue}} {{chartData.unit}}
+                        {{#if details.status.valueDelta}}
+                            ({{details.status.valueDelta}} {{chartData.unit}})
                         {{/if}}
                         {{#if details.status.label}}
                             <br>
         <nav id="navigation" role="navigation">
             <h1><a href="#">WebKit Perf Monitor</a></h1>
             <ul>
-                {{#link-to 'index' tagName='li'}}
-                    {{#link-to 'index'}}Dashboard{{/link-to}}
-                {{/link-to}}
+                {{#each App.Manifest.dashboards}}
+                    {{#if name}}
+                        {{#link-to 'dashboard' name tagName='li'}}
+                            {{#link-to 'dashboard' name}}{{label}}{{/link-to}}
+                        {{/link-to}}
+                    {{/if}}
+                {{/each}}
                 {{#link-to 'charts' tagName='li'}}
                     {{#link-to 'charts'}}Charts{{/link-to}}
                 {{/link-to}}
index f9e97af..bb0aa86 100644 (file)
@@ -288,7 +288,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
         this._yAxisLabels.call(this._yAxis);
         if (this._yAxisUnitContainer)
             this._yAxisUnitContainer.remove();
-        var x = - 3 * this._rem;
+        var x = - 3.2 * this._rem;
         var y = this._contentHeight / 2;
         this._yAxisUnitContainer = this._yAxisLabels.append("text")
             .attr("transform", "rotate(90 0 0) translate(" + y + ", " + (-x) + ")")
index 6ef1d8e..5de4649 100755 (executable)
@@ -92,7 +92,7 @@ App.Repository = App.NameLabelModel.extend({
     },
 });
 
-App.Dashboard = App.Model.extend({
+App.Dashboard = App.NameLabelModel.extend({
     serialized: DS.attr('string'),
     table: function ()
     {
@@ -152,7 +152,7 @@ App.MetricSerializer = App.PlatformSerializer = DS.RESTSerializer.extend({
             metrics: this._normalizeIdMap(payload['metrics']),
             repositories: this._normalizeIdMap(payload['repositories']),
             bugTrackers: this._normalizeIdMap(payload['bugTrackers']),
-            dashboards: [{id: 1, serialized: JSON.stringify(payload['defaultDashboard'])}],
+            dashboards: [],
         };
 
         for (var testId in payload['tests']) {
@@ -173,6 +173,17 @@ App.MetricSerializer = App.PlatformSerializer = DS.RESTSerializer.extend({
             test['metrics'].push(metricId);
         }
 
+        var id = 1;
+        var dashboardsInPayload = payload['dashboards'];
+        for (var dashboardName in dashboardsInPayload) {
+            results['dashboards'].push({
+                id: id,
+                name: dashboardName,
+                serialized: JSON.stringify(dashboardsInPayload[dashboardName])
+            });
+            id++;
+        }
+
         return results;
     },
     _normalizeIdMap: function (idMap)
@@ -211,6 +222,8 @@ App.Manifest = Ember.Controller.extend({
     _metricById: {},
     _builderById: {},
     _repositoryById: {},
+    _dashboardByName: {},
+    _defaultDashboardName: null,
     _fetchPromise: null,
     fetch: function (store)
     {
@@ -223,6 +236,8 @@ App.Manifest = Ember.Controller.extend({
     metric: function (id) { return this._metricById[id]; },
     builder: function (id) { return this._builderById[id]; },
     repository: function (id) { return this._repositoryById[id]; },
+    dashboardByName: function (name) { return this._dashboardByName[name]; },
+    defaultDashboardName: function () { return this._defaultDashboardName; },
     _fetchedManifest: function (store)
     {
         var startTime = Date.now();
@@ -259,7 +274,10 @@ App.Manifest = Ember.Controller.extend({
 
         this.set('bugTrackers', store.all('bugTracker').sortBy('name'));
 
-        this.set('defaultDashboard', store.all('dashboard').objectAt(0));
+        var dashboards = store.all('dashboard').sortBy('name');
+        this.set('dashboards', dashboards);
+        dashboards.forEach(function (dashboard) { self._dashboardByName[dashboard.get('name')] = dashboard; });
+        this._defaultDashboardName = dashboards.length ? dashboards[0].get('name') : null;
     },
     fetchRunsWithPlatformAndMetric: function (store, platformId, metricId)
     {