A/B testing results should be visualized intuitively on v3 UI
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 6 Jan 2016 00:13:12 +0000 (00:13 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 6 Jan 2016 00:13:12 +0000 (00:13 +0000)
https://bugs.webkit.org/show_bug.cgi?id=152496

Rubber-stamped by Chris Dumez.

Add the "stacking block" view of A/B testing results to the analysis task page on v3 UI.

The patch enhances JSON APIs at /api/analysis-task /api/measurement-set/ to reduce the number of
HTTP requests, and adds two UI components: TestGroupResultsTable and AnalysisResultsViewer both
of which inherits from an abstract superclass: ResultsTable.

ResultsTable provides a tabular presentation of measured values in regular measurement sets and
A/B testing results using groups of bar graphs created by BarGraphGroup. TestGroupResultsTable
inherits from this class to display A/B testing configurations and the averaged results for each
configuration, and AnalysisResultsViewer inherits from it to provide an intuitive visualization
of the outcomes of all A/B testing results associated with a given analysis task.

* public/api/analysis-tasks.php:
(main): Add the capability to find the analysis task based on its build request.
This allows /v3/#/analysis/task/?buildRequest=<id> to be hyperlinked on buildbot page.

* public/api/measurement-set.php:
(main): Removed the unused startTime and endTime, and added "analysisTask" to query parameters.
(AnalysisResultsFetcher): Added. Used to fetch measured data associated with every build request
on an analysis task.
(AnalysisResultsFetcher::__construct):
(AnalysisResultsFetcher::fetch): Unlike MeasurementSetFetcher, we fetch the list of commits and
list of measurements separately since there will be a lot less builds and commits than measured
data (since we're fetching measured values for all tests and their metrics).
(AnalysisResultsFetcher::fetch_commits): Fetches commits.
(AnalysisResultsFetcher::format_measurement): Like MeasurementSetFetcher::format_measurement but
with config_type and config_metric since we're returning measured data for all metrics and test
configurations.
(AnalysisResultsFetcher::format_map): Similar to MeasurementSetFetcher::format_map.

* public/v3/components/analysis-results-viewer.js: Added.
(AnalysisResultsViewer): Added.
(AnalysisResultsViewer.prototype.didUpdateResults): This callback is called by AnalysisTaskPage
when A/B testing results become available.
(AnalysisResultsViewer.prototype.render): Overrides ResultsTable's render to highlight the block
representing the currently selected test group.

(AnalysisResultsViewer.prototype.buildRowGroups): Creates a list of rows with "stacking blocks"
that visualizes A/B testing results. The algorithm works as follows: 1. Create all table rows.
2. Find which row is associated with each set in each test group. 3. Layout "blocks".

(AnalysisResultsViewer.prototype._collectRootSetsInTestGroups): Collects root sets from all data
in the measurement set as well as A/B testing **requests** (results may contain more repositories
than requested but they aren't interesting for the purpose of visualizing results for the entire
analysis task).

(AnalysisResultsViewer.prototype._buildRowsForPointsAndTestGroups): Create table rows. First,
create table rows for measurement set points that have a matching test group (i.e. either set A
or set B of an A/B testing uses the same root set as a point). Second, insert a new row for each
root set in each test group which didn't find a matching measurement set point. There is a little
subtlety that some A/B testing may specify revisions for a subset of repositories and/or some A/B
testing results may appear as if it goes back in time with respect to other A/B testing results.
For example, consider creating two A/B test groups for WebKit changes and OS changes separately.
There could be no coherent linearization of those two A/B testing in which both WebKit and OS
versions move forward.

(AnalysisResultsViewer.RootSetInTestGroup): Added. Represents a pair (test group, root set) since
a root set could be shared by multiple test groups.
(AnalysisResultsViewer.TestGroupStackingBlock): Added. A stacked block representing a test group.
(AnalysisResultsViewer.TestGroupStackingBlock.prototype.addRowIndex): Associates a row number with
either set A or set B.
(AnalysisResultsViewer.TestGroupStackingBlock.prototype.createStackingCell): Creates a table cell
for this block.
(AnalysisResultsViewer.TestGroupStackingBlock.prototype.isThin): Returns true if this test group
has failed and this block should look "thin" without any label.
(AnalysisResultsViewer.TestGroupStackingBlock.prototype._computeTestGroupStatus): Computes the
status for this test group.

(AnalysisResultsViewer.TestGroupStackingGrid): Added. AnalysisResultsViewer uses this class to
layout blocks representing test groups.
(AnalysisResultsViewer.TestGroupStackingGrid.prototype.insertBlockToColumn): Inserts a new block
to layout. We keep all test groups doing the same A/B test next to each other.
(AnalysisResultsViewer.TestGroupStackingGrid.prototype.layout): Layouts each block / test group
in the order they are created.
(AnalysisResultsViewer.TestGroupStackingGrid.prototype._layoutBlock): Places the block in the
left-most column that can accommodate it while avoiding columns of a different thin-ness. A column
is thin if its A/B testing has failed, and not thin otherwise.
(AnalysisResultsViewer.TestGroupStackingGrid.prototype.createCellsForRow): Creates table cells for
a given row. For each column, generate a table cell if we're in the first row and the first block
starts in a later row, a block starts in the current row, or the last block ended in the previous
row and the next block or the last row appears later.

* public/v3/components/bar-graph-group.js: Added. A component for showing a group of bar graphs.
(BarGraphGroup): Added. Creates a group of bar graphs with the same value range. It's used by
AnalysisResultsViewer and ResultsTable to show bar graphs to compare values.
(SingleBarGraph): A component created and collectively controlled by BarGraphGroup.

* public/v3/components/results-table.js: Added.
(ResultsTable): An abstract superclass for TestGroupResultsTable and AnalysisResultsViewer.

(ResultsTable.prototype.render): Renders the table. 1. Call "buildRowGroups()" implemented by
a subclass to obtain the list of rows. 2. Compute the list of repositories to show. 3. For each
cell in the table, compute the number of rows to show the same value (for rowspan). 4. Render the
table with an extra list of repositories if exists.

(ResultsTable.prototype._computeRepositoryList): Compute the list of repositories to list
revisions in the table. Omit repositories not present in any row or for which all rows have the
same revision. In the latter case, include it in the extra repositories listed below the table.
This minimizes the amount of redundant information presented to the user.

(ResultsTableRow): Added. Represents a single row in the table. ResultsTable constructs necessary
table cells to tabulate the associated root sets, and shows the associated result using a grouped
bar graph. Additional columns are used by AnalysisResultsViewer to show stacked blocks for A/B
testing groups.

* public/v3/components/test-group-results-table.js: Added.
(TestGroupResultsTable):
(TestGroupResultsTable.prototype.didUpdateResults):
(TestGroupResultsTable.prototype.setTestGroup):
(TestGroupResultsTable.prototype.heading):
(TestGroupResultsTable.prototype.render):
(TestGroupResultsTable.prototype.buildRowGroups):

* public/v3/index.html:
* public/v3/models/analysis-results.js: Added.
(AnalysisResults): Added. Like MeasurementSet, this class represents a set of measured values
associated with a given analysis task.
(AnalysisResults.prototype.find): Returns a measured valued for a given build and metric.
(AnalysisResults.prototype.add): Adds a new measured value. Used by AnalysisResults.fetch.
(AnalysisResults.fetch): Fetches data and creates AnalysisResults for a given analysis task.

* public/v3/models/analysis-task.js:
(AnalysisTask.prototype.startMeasurementId): Added.
(AnalysisTask.prototype.endMeasurementId): Added.
(AnalysisTask.fetchByBuildRequestId): Added.
(AnalysisTask._fetchSubset): Uses DataModelObject.cachedFetch.

* public/v3/models/build-request.js: Added.
(BuildRequest): Added. Represents a single A/B testing request associated with a test group.

* public/v3/models/builder.js:
(Build): Added. Represents a build associated with a given A/B testing result.

* public/v3/models/commit-log.js:
(CommitLog): Made this class inherit from DataModelObject.
(CommitLog.ensureSingleton): Added. Finds the singleton object created for a given revision
in the specified repository. This helps RootSet and other classes compare commits fast.
(CommitLog.prototype.repository): Added.
(CommitLog.fetchBetweenRevisions): Uses CommitLog.ensureSingleton.

* public/v3/models/data-model.js:
(DataModelObject):
(DataModelObject.namedStaticMap): Added.
(DataModelObject.ensureNamedStaticMap): Renamed from namedStaticMap instead of implicitly
assuming that the non-static version always creates the map.
(DataModelObject.prototype.namedStaticMap): Added.
(DataModelObject.cachedFetch): Extracted from AnalysisTask._fetchSubset so that TestGroup's
fetchByTask could also use it.
(LabeledObject):

* public/v3/models/measurement-adaptor.js: Added.
(MeasurementAdaptor): Extracted from MeasurementCluster. This class is responsible for
re-formatting the data received via /api/measurement-set JSON API inside the v3 UI.
(MeasurementAdaptor.prototype.extractId): Added.
(MeasurementAdaptor.prototype.adoptToAnalysisResults): Added. Used by AnalysisResults.
(MeasurementAdaptor.aggregateAnalysisResults): Added. Used by TestGroupResultsTable to
aggregate results for each test configuration; e.g. computing the average for set A.
(MeasurementAdaptor.prototype.adoptToSeries): Extracted from MeasurementCluster.addToSeries.
Added rootSet() to each point. This allows AnalysisResultsViewer to compare them against root
sets associated with A/B testing results.
(MeasurementAdaptor.computeConfidenceInterval): Moved from MeasurementCluster.

* public/v3/models/measurement-cluster.js:
(MeasurementCluster):
(MeasurementCluster.prototype.addToSeries):

* public/v3/models/repository.js:
(Repository.prototype.hasUrlForRevision): Added.

* public/v3/models/root-set.js: Added.
(RootSet): Added. Represents a set of commits in measured results.
(MeasurementRootSet): Added. Ditto for results associated with A/B testing.

* public/v3/models/test-group.js: Added.
(TestGroup): Added. Represents a A/B testing on analysis task.
(TestGroup.prototype.createdAt): Added.
(TestGroup.prototype.buildRequests): Returns the list of build requests associated with this
A/B testing.
(TestGroup.prototype.addBuildRequest): Added. Used by BuildRequest's constructor to associate
itself with this group.
(TestGroup.prototype.didSetResult): Added. Called by BuildRequest.setResult when measured
values are fetched and associated with a build request in this group.

* public/v3/models/test.js:
(Test):

* public/v3/pages/analysis-task-page.js:
(AnalysisTaskPage):
(AnalysisTaskPage.prototype.updateFromSerializedState): Fetch the analysis task, test groups
associated with it, and all A/B testing results based on the task id or the build request id
specified in the URL.
(AnalysisTaskPage.prototype._didFetchTask): Added. Start fetching the measured data. This is
the data on charts page for which this analysis task was created, not results of A/B testing.
(AnalysisTaskPage.prototype._didFetchMeasurement): Added. Display the fetched data in a table
inside AnalysisResultsViewer.
(AnalysisTaskPage.prototype._didFetchTestGroups): Added. Display the list of A/B test groups
as well as the results of the first A/B testing.
(AnalysisTaskPage.prototype._didFetchAnalysisResults): Added.
(AnalysisTaskPage.prototype._assignTestResultsIfPossible): Added. Once both the analysis task,
A/B test groups as well as their results are fetched, update build requests in each test group
with their results.
(AnalysisTaskPage.prototype.render): Show the list of test groups and highlight the currently
selected one.
(AnalysisTaskPage.prototype._showTestGroup): Added. A callback used by AnalysisResultsViewer
and TestGroupResultsTable to notify this class when the user selects a new test group.
(AnalysisTaskPage.htmlTemplate): Updated the template.
(AnalysisTaskPage.cssTemplate): Ditto.

* public/v3/pages/charts-page.js:
(ChartsPage.createStateForAnalysisTask): Added. Creates a URL state object for opening a chart
associated with an analysis task.

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

22 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/api/analysis-tasks.php
Websites/perf.webkit.org/public/api/measurement-set.php
Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/bar-graph-group.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/results-table.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/test-group-results-table.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/analysis-results.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/analysis-task.js
Websites/perf.webkit.org/public/v3/models/build-request.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/builder.js
Websites/perf.webkit.org/public/v3/models/commit-log.js
Websites/perf.webkit.org/public/v3/models/data-model.js
Websites/perf.webkit.org/public/v3/models/measurement-adaptor.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/measurement-cluster.js
Websites/perf.webkit.org/public/v3/models/repository.js
Websites/perf.webkit.org/public/v3/models/root-set.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/test-group.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/test.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js
Websites/perf.webkit.org/public/v3/pages/charts-page.js

index a322b79..8e30cd4 100644 (file)
@@ -1,3 +1,222 @@
+2016-01-05  Ryosuke Niwa  <rniwa@webkit.org>
+
+        A/B testing results should be visualized intuitively on v3 UI
+        https://bugs.webkit.org/show_bug.cgi?id=152496
+
+        Rubber-stamped by Chris Dumez.
+
+        Add the "stacking block" view of A/B testing results to the analysis task page on v3 UI.
+
+        The patch enhances JSON APIs at /api/analysis-task /api/measurement-set/ to reduce the number of
+        HTTP requests, and adds two UI components: TestGroupResultsTable and AnalysisResultsViewer both
+        of which inherits from an abstract superclass: ResultsTable.
+
+        ResultsTable provides a tabular presentation of measured values in regular measurement sets and
+        A/B testing results using groups of bar graphs created by BarGraphGroup. TestGroupResultsTable
+        inherits from this class to display A/B testing configurations and the averaged results for each
+        configuration, and AnalysisResultsViewer inherits from it to provide an intuitive visualization
+        of the outcomes of all A/B testing results associated with a given analysis task.
+
+        * public/api/analysis-tasks.php:
+        (main): Add the capability to find the analysis task based on its build request.
+        This allows /v3/#/analysis/task/?buildRequest=<id> to be hyperlinked on buildbot page.
+
+        * public/api/measurement-set.php:
+        (main): Removed the unused startTime and endTime, and added "analysisTask" to query parameters.
+        (AnalysisResultsFetcher): Added. Used to fetch measured data associated with every build request
+        on an analysis task.
+        (AnalysisResultsFetcher::__construct):
+        (AnalysisResultsFetcher::fetch): Unlike MeasurementSetFetcher, we fetch the list of commits and
+        list of measurements separately since there will be a lot less builds and commits than measured
+        data (since we're fetching measured values for all tests and their metrics).
+        (AnalysisResultsFetcher::fetch_commits): Fetches commits.
+        (AnalysisResultsFetcher::format_measurement): Like MeasurementSetFetcher::format_measurement but
+        with config_type and config_metric since we're returning measured data for all metrics and test
+        configurations.
+        (AnalysisResultsFetcher::format_map): Similar to MeasurementSetFetcher::format_map.
+
+        * public/v3/components/analysis-results-viewer.js: Added.
+        (AnalysisResultsViewer): Added. 
+        (AnalysisResultsViewer.prototype.didUpdateResults): This callback is called by AnalysisTaskPage
+        when A/B testing results become available.
+        (AnalysisResultsViewer.prototype.render): Overrides ResultsTable's render to highlight the block
+        representing the currently selected test group.
+
+        (AnalysisResultsViewer.prototype.buildRowGroups): Creates a list of rows with "stacking blocks"
+        that visualizes A/B testing results. The algorithm works as follows: 1. Create all table rows.
+        2. Find which row is associated with each set in each test group. 3. Layout "blocks".
+
+        (AnalysisResultsViewer.prototype._collectRootSetsInTestGroups): Collects root sets from all data
+        in the measurement set as well as A/B testing **requests** (results may contain more repositories
+        than requested but they aren't interesting for the purpose of visualizing results for the entire
+        analysis task).
+
+        (AnalysisResultsViewer.prototype._buildRowsForPointsAndTestGroups): Create table rows. First,
+        create table rows for measurement set points that have a matching test group (i.e. either set A
+        or set B of an A/B testing uses the same root set as a point). Second, insert a new row for each
+        root set in each test group which didn't find a matching measurement set point. There is a little
+        subtlety that some A/B testing may specify revisions for a subset of repositories and/or some A/B
+        testing results may appear as if it goes back in time with respect to other A/B testing results.
+        For example, consider creating two A/B test groups for WebKit changes and OS changes separately.
+        There could be no coherent linearization of those two A/B testing in which both WebKit and OS
+        versions move forward.
+
+        (AnalysisResultsViewer.RootSetInTestGroup): Added. Represents a pair (test group, root set) since
+        a root set could be shared by multiple test groups.
+        (AnalysisResultsViewer.TestGroupStackingBlock): Added. A stacked block representing a test group.
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype.addRowIndex): Associates a row number with
+        either set A or set B.
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype.createStackingCell): Creates a table cell
+        for this block.
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype.isThin): Returns true if this test group
+        has failed and this block should look "thin" without any label.
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype._computeTestGroupStatus): Computes the
+        status for this test group.
+
+        (AnalysisResultsViewer.TestGroupStackingGrid): Added. AnalysisResultsViewer uses this class to
+        layout blocks representing test groups.
+        (AnalysisResultsViewer.TestGroupStackingGrid.prototype.insertBlockToColumn): Inserts a new block
+        to layout. We keep all test groups doing the same A/B test next to each other.
+        (AnalysisResultsViewer.TestGroupStackingGrid.prototype.layout): Layouts each block / test group
+        in the order they are created.
+        (AnalysisResultsViewer.TestGroupStackingGrid.prototype._layoutBlock): Places the block in the
+        left-most column that can accommodate it while avoiding columns of a different thin-ness. A column
+        is thin if its A/B testing has failed, and not thin otherwise.
+        (AnalysisResultsViewer.TestGroupStackingGrid.prototype.createCellsForRow): Creates table cells for
+        a given row. For each column, generate a table cell if we're in the first row and the first block
+        starts in a later row, a block starts in the current row, or the last block ended in the previous
+        row and the next block or the last row appears later.
+
+        * public/v3/components/bar-graph-group.js: Added. A component for showing a group of bar graphs.
+        (BarGraphGroup): Added. Creates a group of bar graphs with the same value range. It's used by
+        AnalysisResultsViewer and ResultsTable to show bar graphs to compare values.
+        (SingleBarGraph): A component created and collectively controlled by BarGraphGroup.
+
+        * public/v3/components/results-table.js: Added.
+        (ResultsTable): An abstract superclass for TestGroupResultsTable and AnalysisResultsViewer.
+
+        (ResultsTable.prototype.render): Renders the table. 1. Call "buildRowGroups()" implemented by
+        a subclass to obtain the list of rows. 2. Compute the list of repositories to show. 3. For each
+        cell in the table, compute the number of rows to show the same value (for rowspan). 4. Render the
+        table with an extra list of repositories if exists.
+
+        (ResultsTable.prototype._computeRepositoryList): Compute the list of repositories to list
+        revisions in the table. Omit repositories not present in any row or for which all rows have the
+        same revision. In the latter case, include it in the extra repositories listed below the table.
+        This minimizes the amount of redundant information presented to the user.
+
+        (ResultsTableRow): Added. Represents a single row in the table. ResultsTable constructs necessary
+        table cells to tabulate the associated root sets, and shows the associated result using a grouped
+        bar graph. Additional columns are used by AnalysisResultsViewer to show stacked blocks for A/B
+        testing groups.
+
+        * public/v3/components/test-group-results-table.js: Added.
+        (TestGroupResultsTable):
+        (TestGroupResultsTable.prototype.didUpdateResults):
+        (TestGroupResultsTable.prototype.setTestGroup):
+        (TestGroupResultsTable.prototype.heading):
+        (TestGroupResultsTable.prototype.render):
+        (TestGroupResultsTable.prototype.buildRowGroups):
+
+        * public/v3/index.html:
+        * public/v3/models/analysis-results.js: Added.
+        (AnalysisResults): Added. Like MeasurementSet, this class represents a set of measured values
+        associated with a given analysis task.
+        (AnalysisResults.prototype.find): Returns a measured valued for a given build and metric.
+        (AnalysisResults.prototype.add): Adds a new measured value. Used by AnalysisResults.fetch.
+        (AnalysisResults.fetch): Fetches data and creates AnalysisResults for a given analysis task.
+
+        * public/v3/models/analysis-task.js:
+        (AnalysisTask.prototype.startMeasurementId): Added.
+        (AnalysisTask.prototype.endMeasurementId): Added.
+        (AnalysisTask.fetchByBuildRequestId): Added.
+        (AnalysisTask._fetchSubset): Uses DataModelObject.cachedFetch.
+
+        * public/v3/models/build-request.js: Added.
+        (BuildRequest): Added. Represents a single A/B testing request associated with a test group.
+
+        * public/v3/models/builder.js:
+        (Build): Added. Represents a build associated with a given A/B testing result.
+
+        * public/v3/models/commit-log.js:
+        (CommitLog): Made this class inherit from DataModelObject.
+        (CommitLog.ensureSingleton): Added. Finds the singleton object created for a given revision
+        in the specified repository. This helps RootSet and other classes compare commits fast.
+        (CommitLog.prototype.repository): Added.
+        (CommitLog.fetchBetweenRevisions): Uses CommitLog.ensureSingleton.
+
+        * public/v3/models/data-model.js:
+        (DataModelObject):
+        (DataModelObject.namedStaticMap): Added.
+        (DataModelObject.ensureNamedStaticMap): Renamed from namedStaticMap instead of implicitly
+        assuming that the non-static version always creates the map.
+        (DataModelObject.prototype.namedStaticMap): Added.
+        (DataModelObject.cachedFetch): Extracted from AnalysisTask._fetchSubset so that TestGroup's
+        fetchByTask could also use it.
+        (LabeledObject):
+
+        * public/v3/models/measurement-adaptor.js: Added.
+        (MeasurementAdaptor): Extracted from MeasurementCluster. This class is responsible for
+        re-formatting the data received via /api/measurement-set JSON API inside the v3 UI.
+        (MeasurementAdaptor.prototype.extractId): Added.
+        (MeasurementAdaptor.prototype.adoptToAnalysisResults): Added. Used by AnalysisResults.
+        (MeasurementAdaptor.aggregateAnalysisResults): Added. Used by TestGroupResultsTable to
+        aggregate results for each test configuration; e.g. computing the average for set A.
+        (MeasurementAdaptor.prototype.adoptToSeries): Extracted from MeasurementCluster.addToSeries.
+        Added rootSet() to each point. This allows AnalysisResultsViewer to compare them against root
+        sets associated with A/B testing results.
+        (MeasurementAdaptor.computeConfidenceInterval): Moved from MeasurementCluster.
+
+        * public/v3/models/measurement-cluster.js:
+        (MeasurementCluster):
+        (MeasurementCluster.prototype.addToSeries):
+
+        * public/v3/models/repository.js:
+        (Repository.prototype.hasUrlForRevision): Added.
+
+        * public/v3/models/root-set.js: Added.
+        (RootSet): Added. Represents a set of commits in measured results.
+        (MeasurementRootSet): Added. Ditto for results associated with A/B testing.
+
+        * public/v3/models/test-group.js: Added.
+        (TestGroup): Added. Represents a A/B testing on analysis task.
+        (TestGroup.prototype.createdAt): Added.
+        (TestGroup.prototype.buildRequests): Returns the list of build requests associated with this
+        A/B testing.
+        (TestGroup.prototype.addBuildRequest): Added. Used by BuildRequest's constructor to associate
+        itself with this group.
+        (TestGroup.prototype.didSetResult): Added. Called by BuildRequest.setResult when measured
+        values are fetched and associated with a build request in this group.
+
+        * public/v3/models/test.js:
+        (Test):
+
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskPage):
+        (AnalysisTaskPage.prototype.updateFromSerializedState): Fetch the analysis task, test groups
+        associated with it, and all A/B testing results based on the task id or the build request id
+        specified in the URL.
+        (AnalysisTaskPage.prototype._didFetchTask): Added. Start fetching the measured data. This is
+        the data on charts page for which this analysis task was created, not results of A/B testing.
+        (AnalysisTaskPage.prototype._didFetchMeasurement): Added. Display the fetched data in a table
+        inside AnalysisResultsViewer.
+        (AnalysisTaskPage.prototype._didFetchTestGroups): Added. Display the list of A/B test groups
+        as well as the results of the first A/B testing.
+        (AnalysisTaskPage.prototype._didFetchAnalysisResults): Added.
+        (AnalysisTaskPage.prototype._assignTestResultsIfPossible): Added. Once both the analysis task,
+        A/B test groups as well as their results are fetched, update build requests in each test group
+        with their results.
+        (AnalysisTaskPage.prototype.render): Show the list of test groups and highlight the currently
+        selected one.
+        (AnalysisTaskPage.prototype._showTestGroup): Added. A callback used by AnalysisResultsViewer
+        and TestGroupResultsTable to notify this class when the user selects a new test group.
+        (AnalysisTaskPage.htmlTemplate): Updated the template.
+        (AnalysisTaskPage.cssTemplate): Ditto.
+
+        * public/v3/pages/charts-page.js:
+        (ChartsPage.createStateForAnalysisTask): Added. Creates a URL state object for opening a chart
+        associated with an analysis task.
+
 2015-12-22  Ryosuke Niwa  <rniwa@webkit.org>
 
         Analysis task page is slow to load
index e0c8594..47d2159 100644 (file)
@@ -10,9 +10,15 @@ function main($path) {
     if (count($path) > 1)
         exit_with_error('InvalidRequest');
 
+    $build_request_id = array_get($_GET, 'buildRequest');
     $task_id = count($path) > 0 && $path[0] ? $path[0] : array_get($_GET, 'id');
 
-    if ($task_id) {
+    if ($build_request_id) {
+        $tasks = $db->query_and_fetch_all('SELECT analysis_tasks.* FROM build_requests, analysis_test_groups, analysis_tasks
+            WHERE request_id = $1 AND request_group = testgroup_id AND testgroup_task = task_id', array(intval($build_request_id)));
+        if (!$tasks)
+            exit_with_error('TaskNotFound', array('buildRequest' => $build_request_id));
+    } else if ($task_id) {
         $task_id = intval($task_id);
         $task = $db->select_first_row('analysis_tasks', 'task', array('id' => $task_id));
         if (!$task)
index 7a0f2c1..54caa3a 100644 (file)
@@ -6,24 +6,23 @@ function main() {
     $program_start_time = microtime(true);
 
     $arguments = validate_arguments($_GET, array(
-        'platform' => 'int',
-        'metric' => 'int',
-        'testGroup' => 'int?',
-        'startTime' => 'int?',
-        'endTime' => 'int?'));
+        'platform' => 'int?',
+        'metric' => 'int?',
+        'analysisTask' => 'int?'));
 
     $platform_id = $arguments['platform'];
     $metric_id = $arguments['metric'];
-
-    $start_time = $arguments['startTime'];
-    $end_time = $arguments['endTime'];
-    if (!!$start_time != !!$end_time)
-        exit_with_error('InvalidTimeRange', array('startTime' => $start_time, 'endTime' => $end_time));
+    $task_id = $arguments['analysisTask'];
+    if (!(($platform_id && $metric_id && !$task_id) || ($task_id && !$platform_id && !$metric_id)))
+        exit_with_error('AmbiguousRequest');
 
     $db = new Database;
     if (!$db->connect())
         exit_with_error('DatabaseConnectionFailure');
 
+    if ($task_id)
+        exit_with_success((new AnalysisResultsFetcher($db, $task_id))->fetch());
+
     $fetcher = new MeasurementSetFetcher($db);
     if (!$fetcher->fetch_config_list($platform_id, $metric_id)) {
         exit_with_error('ConfigurationNotFound',
@@ -214,6 +213,76 @@ class MeasurementSetFetcher {
     }
 }
 
+class AnalysisResultsFetcher {
+
+    function __construct($db, $task_id) {
+        $this->db = $db;
+        $this->task_id = $task_id;
+        $this->build_to_commits = array();
+    }
+
+    function fetch()
+    {
+        $start_time = microtime(TRUE);
+
+        // Fetch commmits separately from test_runs since number of builds is much smaller than number of runs here.
+        $this->fetch_commits();
+
+        $query = $this->db->query('SELECT test_runs.*, builds.*, test_configurations.*
+            FROM builds,
+                test_runs JOIN test_configurations ON run_config = config_id,
+                build_requests JOIN analysis_test_groups ON request_group = testgroup_id
+            WHERE run_build = build_id AND build_id = request_build
+                AND testgroup_task = $1 AND run_config = config_id', array($this->task_id));
+
+        $results = array();
+        while ($row = $this->db->fetch_next_row($query))
+            array_push($results, $this->format_measurement($row));
+
+        return array(
+            'formatMap' => self::format_map(),
+            'measurements' => $results,
+            'elapsedTime' => (microtime(TRUE) - $start_time) * 1000);
+    }
+
+    function fetch_commits()
+    {
+        $query = $this->db->query('SELECT commit_build, commit_repository, commit_revision, commit_time
+            FROM commits, build_commits, build_requests, analysis_test_groups
+            WHERE commit_id = build_commit AND commit_build = request_build
+                AND request_group = testgroup_id AND testgroup_task = $1', array($this->task_id));
+        while ($row = $this->db->fetch_next_row($query)) {
+            $commit_time = Database::to_js_time($row['commit_time']);
+            array_push(array_ensure_item_has_array($this->build_to_commits, $row['commit_build']),
+                array($row['commit_repository'], $row['commit_revision'], $commit_time));
+        }
+    }
+
+    function format_measurement($row)
+    {
+        $build_id = $row['build_id'];
+        return array(
+            intval($row['run_id']),
+            floatval($row['run_mean_cache']),
+            intval($row['run_iteration_count_cache']),
+            floatval($row['run_sum_cache']),
+            floatval($row['run_square_sum_cache']),
+            $this->build_to_commits[$build_id],
+            intval($build_id),
+            Database::to_js_time($row['build_time']),
+            $row['build_number'],
+            intval($row['build_builder']),
+            intval($row['config_metric']),
+            $row['config_type']);
+    }
+
+    static function format_map()
+    {
+        return array('id', 'mean', 'iterationCount', 'sum', 'squareSum', 'revisions',
+            'build', 'buildTime', 'buildNumber', 'builder', 'metric', 'configType');
+    }
+}
+
 main();
 
 ?>
diff --git a/Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js b/Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js
new file mode 100644 (file)
index 0000000..f84483f
--- /dev/null
@@ -0,0 +1,494 @@
+
+class AnalysisResultsViewer extends ResultsTable {
+    constructor()
+    {
+        super('analysis-results-viewer');
+        this._smallerIsBetter = false;
+        this._startPoint = null;
+        this._endPoint = null;
+        this._testGroups = null;
+        this._currentTestGroup = null;
+        this._renderedCurrentTestGroup = null;
+        this._shouldRenderTable = true;
+        this._additionalHeading = null;
+        this._testGroupCallback = null;
+    }
+
+    setTestGroupCallback(callback) { this._testGroupCallback = callback; }
+
+    setSmallerIsBetter(smallerIsBetter)
+    {
+        this._smallerIsBetter = smallerIsBetter;
+        this._shouldRenderTable = true;
+    }
+
+    setCurrentTestGroup(testGroup)
+    {
+        this._currentTestGroup = testGroup;
+        this.render();
+    }
+
+    setPoints(startPoint, endPoint)
+    {
+        this._startPoint = startPoint;
+        this._endPoint = endPoint;
+        this._shouldRenderTable = true;
+    }
+
+    setTestGroups(testGroups)
+    {
+        this._testGroups = testGroups;
+        this._shouldRenderTable = true;
+    }
+
+    didUpdateResults()
+    {
+        this._shouldRenderTable = true;
+    }
+
+    render()
+    {
+        if (!this._valueFormatter || !this._startPoint)
+            return;
+
+        Instrumentation.startMeasuringTime('AnalysisResultsViewer', 'render');
+
+        if (this._shouldRenderTable) {
+            this._shouldRenderTable = false;
+            this._renderedCurrentTestGroup = null;
+
+            Instrumentation.startMeasuringTime('AnalysisResultsViewer', 'renderTable');
+            super.render();
+            Instrumentation.endMeasuringTime('AnalysisResultsViewer', 'renderTable');
+        }
+
+        if (this._currentTestGroup != this._renderedCurrentTestGroup) {
+            if (this._renderedCurrentTestGroup) {
+                var className = this._classForTestGroup(this._renderedCurrentTestGroup);
+                var element = this.content().querySelector('.' + className);
+                if (element)
+                    element.classList.remove('selected');
+            }
+            if (this._currentTestGroup) {
+                var className = this._classForTestGroup(this._currentTestGroup);
+                var element = this.content().querySelector('.' + className);
+                if (element)
+                    element.classList.add('selected');
+            }
+            this._renderedCurrentTestGroup = this._currentTestGroup;
+        }
+
+        Instrumentation.endMeasuringTime('AnalysisResultsViewer', 'render');
+    }
+
+    heading() { return [ComponentBase.createElement('th', 'Point')]; }
+    additionalHeading() { return this._additionalHeading; }
+
+    buildRowGroups()
+    {
+        Instrumentation.startMeasuringTime('AnalysisResultsViewer', 'buildRowGroups');
+
+        var testGroups = this._testGroups || [];
+        var rootSetsInTestGroups = this._collectRootSetsInTestGroups(testGroups);
+
+        var rowToMatchingRootSets = new Map;
+        var rowList = this._buildRowsForPointsAndTestGroups(rootSetsInTestGroups, rowToMatchingRootSets);
+
+        var testGroupLayoutMap = new Map;
+        var self = this;
+        rowList.forEach(function (row, rowIndex) {
+            var matchingRootSets = rowToMatchingRootSets.get(row);
+            for (var entry of matchingRootSets) {
+                var testGroup = entry.testGroup();
+
+                var block = testGroupLayoutMap.get(testGroup);
+                if (!block) {
+                    block = new AnalysisResultsViewer.TestGroupStackingBlock(
+                        testGroup, self._smallerIsBetter, self._classForTestGroup(testGroup), self._openStackingBlock.bind(self, testGroup));
+                    testGroupLayoutMap.set(testGroup, block);
+                }
+                block.addRowIndex(entry, rowIndex);
+            }
+        });
+
+        var grid = new AnalysisResultsViewer.TestGroupStackingGrid(rowList.length);
+        for (var testGroup of testGroups) {
+            var block = testGroupLayoutMap.get(testGroup);
+            if (block)
+                grid.insertBlockToColumn(block);
+        }
+
+        grid.layout();
+        for (var rowIndex = 0; rowIndex < rowList.length; rowIndex++)
+            rowList[rowIndex].setAdditionalColumns(grid.createCellsForRow(rowIndex));
+
+        this._additionalHeading = grid._columns ? ComponentBase.createElement('td', {colspan: grid._columns.length + 1, class: 'stacking-block'}) : [];
+
+        Instrumentation.endMeasuringTime('AnalysisResultsViewer', 'buildRowGroups');
+
+        return [{rows: rowList}];
+    }
+
+    _collectRootSetsInTestGroups(testGroups)
+    {
+        if (!this._testGroups)
+            return [];
+
+        var rootSetsInTestGroups = [];
+        for (var group of this._testGroups) {
+            var sortedSets = group.requestedRootSets();
+            for (var i = 0; i < sortedSets.length; i++)
+                rootSetsInTestGroups.push(new AnalysisResultsViewer.RootSetInTestGroup(group, sortedSets[i]));
+        }
+
+        return rootSetsInTestGroups;
+    }
+
+    _buildRowsForPointsAndTestGroups(rootSetsInTestGroups, rowToMatchingRootSets)
+    {
+        console.assert(this._startPoint.series == this._endPoint.series);
+        var rowList = [];
+        var pointAfterEnd = this._endPoint.series.nextPoint(this._endPoint);
+        var rootSetsWithPoints = new Set;
+        var pointIndex = 0;
+        for (var point = this._startPoint; point && point != pointAfterEnd; point = point.series.nextPoint(point), pointIndex++) {
+            var rootSetInPoint = point.rootSet();
+            var matchingRootSets = [];
+            for (var entry of rootSetsInTestGroups) {
+                if (rootSetInPoint.equals(entry.rootSet())) {
+                    matchingRootSets.push(entry);
+                    rootSetsWithPoints.add(entry);
+                }
+            }
+
+            if (!matchingRootSets.length && point != this._startPoint && point != this._endPoint)
+                continue;
+
+            var row = new ResultsTableRow(pointIndex.toString(), rootSetInPoint);
+            row.setResult(point);
+
+            rowToMatchingRootSets.set(row, matchingRootSets);
+            rowList.push(row);
+        }
+
+        rootSetsInTestGroups.forEach(function (entry) {
+            if (rootSetsWithPoints.has(entry))
+                return;
+
+            for (var i = 0; i < rowList.length; i++) {
+                var row = rowList[i];
+                if (row.rootSet().equals(entry.rootSet())) {
+                    rowToMatchingRootSets.get(row).push(entry);
+                    return;
+                }
+            }
+
+            var groupTime = entry.rootSet().latestCommitTime();
+            for (var i = 0; i < rowList.length; i++) {
+                var rowTime = rowList[i].rootSet().latestCommitTime();
+                if (rowTime > groupTime) {
+                    var newRow = new ResultsTableRow(null, entry.rootSet());
+                    rowToMatchingRootSets.set(newRow, [entry]);
+                    rowList.splice(i, 0, newRow);
+                    return;
+                }
+
+                if (rowTime == groupTime) {
+                    // Missing some commits. Do as best as we can to avoid going backwards in time.
+                    var repositoriesInNewRow = entry.rootSet().repositories();
+                    for (var j = i; j < rowList.length; j++) {
+                        for (var repository of repositoriesInNewRow) {
+                            var newCommit = entry.rootSet().commitForRepository(repository);
+                            var rowCommit = rowList[j].rootSet().commitForRepository(repository);
+                            if (!rowCommit || newCommit.time() < rowCommit.time()) {
+                                var row = new ResultsTableRow(null, entry.rootSet());
+                                rowToMatchingRootSets.set(row, [entry]);
+                                rowList.splice(j, 0, row);
+                                return;
+                            }
+                        }
+                    }
+                }
+            }
+
+            var newRow = new ResultsTableRow(null, entry.rootSet());
+            rowToMatchingRootSets.set(newRow, [entry]);
+            rowList.push(newRow);
+        });
+
+        return rowList;
+    }
+
+    _classForTestGroup(testGroup)
+    {
+        return 'stacked-test-group-' + testGroup.id();
+    }
+
+    _openStackingBlock(testGroup)
+    {
+        if (this._testGroupCallback)
+            this._testGroupCallback(testGroup);
+    }
+
+    static htmlTemplate()
+    {
+        return `<section class="analysis-view">${ResultsTable.htmlTemplate()}</section>`;
+    }
+
+    static cssTemplate()
+    {
+        return ResultsTable.cssTemplate() + `
+            .analysis-view .stacking-block {
+                border: solid 1px #fff;
+                cursor: pointer;
+            }
+
+            .analysis-view .stacking-block a {
+                display: block;
+                text-decoration: none;
+                color: inherit;
+                font-size: 0.8rem;
+                padding: 0 0.1rem;
+            }
+
+            .analysis-view .stacking-block:not(.failed) {
+                color: white;
+                opacity: 1;
+            }
+
+            .analysis-view .stacking-block.selected {
+
+            }
+
+            .analysis-view .stacking-block.failed {
+                background: rgba(128, 51, 128, 0.4);
+            }
+            .analysis-view .stacking-block.failed:hover {
+                background: rgba(128, 51, 128, 0.6);
+            }
+            .analysis-view .stacking-block.failed.selected {
+                background: rgba(128, 51, 128, 1);
+                color: white;
+            }
+
+            .analysis-view .stacking-block.unchanged {
+                background: rgba(128, 128, 128, 0.3);
+                color: black;
+            }
+            .analysis-view .stacking-block.unchanged:hover {
+                background: rgba(128, 128, 128, 0.6);
+            }
+            .analysis-view .stacking-block.unchanged.selected {
+                background: rgba(128, 128, 128, 1);
+                color: white;
+            }
+
+            .analysis-view .stacking-block.worse {
+                background: rgba(255, 102, 102, 0.4);
+                color: black;
+            }
+            .analysis-view .stacking-block.worse:hover {
+                background: rgba(255, 102, 102, 0.6);
+            }
+            .analysis-view .stacking-block.worse.selected {
+                background: rgba(255, 102, 102, 1);
+                color: white;
+            }
+
+            .analysis-view .stacking-block.better {
+                background: rgba(102, 102, 255, 0.4);
+                color: black;
+            }
+            .analysis-view .stacking-block.better:hover {
+                background: rgba(102, 102, 255, 0.6);
+            }
+            .analysis-view .stacking-block.better.selected {
+                background: rgba(102, 102, 255, 1);
+                color: white;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('analysis-results-viewer', AnalysisResultsViewer);
+
+AnalysisResultsViewer.RootSetInTestGroup = class {
+    constructor(testGroup, rootSet)
+    {
+        console.assert(testGroup instanceof TestGroup);
+        console.assert(rootSet instanceof RootSet);
+        this._testGroup = testGroup;
+        this._rootSet = rootSet;
+    }
+
+    testGroup() { return this._testGroup; }
+    rootSet() { return this._rootSet; }
+}
+
+AnalysisResultsViewer.TestGroupStackingBlock = class {
+    constructor(testGroup, smallerIsBetter, className, callback)
+    {
+        this._testGroup = testGroup;
+        this._smallerIsBetter = smallerIsBetter;
+        this._rootSetIndexRowIndexMap = [];
+        this._className = className;
+        this._label = '-';
+        this._status = null;
+        this._callback = callback;
+    }
+
+    addRowIndex(rootSetInTestGroup, rowIndex)
+    {
+        console.assert(rootSetInTestGroup instanceof AnalysisResultsViewer.RootSetInTestGroup);
+        this._rootSetIndexRowIndexMap.push({rootSet: rootSetInTestGroup.rootSet(), rowIndex: rowIndex});
+    }
+
+    testGroup() { return this._testGroup; }
+
+    createStackingCell()
+    {
+        this._computeTestGroupStatus();
+
+        var title = this._testGroup.label();
+        return ComponentBase.createElement('td', {
+            rowspan: this.endRowIndex() - this.startRowIndex() + 1,
+            title: title,
+            class: 'stacking-block ' + this._className + ' ' + this._status,
+            onclick: this._callback,
+        }, ComponentBase.createLink(this._label, title, this._callback));
+    }
+
+    isComplete() { return this._rootSetIndexRowIndexMap.length >= 2; }
+
+    startRowIndex() { return this._rootSetIndexRowIndexMap[0].rowIndex; }
+    endRowIndex() { return this._rootSetIndexRowIndexMap[this._rootSetIndexRowIndexMap.length - 1].rowIndex; }
+    isThin()
+    {
+        this._computeTestGroupStatus();
+        return this._status == 'failed';
+    }
+
+    _computeTestGroupStatus()
+    {
+        if (this._status || !this.isComplete())
+            return;
+
+        console.assert(this._rootSetIndexRowIndexMap.length <= 2); // FIXME: Support having more root sets.
+
+        var beforeValues = this._valuesForRootSet(this._rootSetIndexRowIndexMap[0].rootSet);
+        var afterValues = this._valuesForRootSet(this._rootSetIndexRowIndexMap[1].rootSet);
+
+        var status = 'failed';
+        var beforeMean = Statistics.sum(beforeValues) / beforeValues.length;
+        var afterMean = Statistics.sum(afterValues) / afterValues.length;
+        if (beforeValues.length && afterValues.length) {
+            var diff = afterMean - beforeMean;
+            this._label = (diff / beforeMean * 100).toFixed(2) + '%';
+            status = 'unchanged';
+            if (Statistics.testWelchsT(beforeValues, afterValues))
+                status = diff < 0 == this._smallerIsBetter ? 'better' : 'worse';
+        }
+
+        this._status = status;
+    }
+
+    _valuesForRootSet(rootSet)
+    {
+        var requests = this._testGroup.requestsForRootSet(rootSet);
+        var values = [];
+        for (var request of requests) {
+            if (request.result())
+                values.push(request.result().value);
+        }
+        return values;
+    }
+}
+
+AnalysisResultsViewer.TestGroupStackingGrid = class {
+    constructor(rowCount)
+    {
+        this._blocks = [];
+        this._columns = null;
+        this._rowCount = rowCount;
+    }
+
+    insertBlockToColumn(newBlock)
+    {
+        console.assert(newBlock instanceof AnalysisResultsViewer.TestGroupStackingBlock);
+        for (var i = this._blocks.length - 1; i >= 0; i--) {
+            var currentBlock = this._blocks[i];
+            if (currentBlock.startRowIndex() == newBlock.startRowIndex()
+                && currentBlock.endRowIndex() == newBlock.endRowIndex()) {
+                this._blocks.splice(i + 1, 0, newBlock);
+                return;
+            }
+        }
+        this._blocks.push(newBlock);
+    }
+
+    layout()
+    {
+        this._columns = [];
+        for (var block of this._blocks)
+            this._layoutBlock(block);
+    }
+
+    _layoutBlock(newBlock)
+    {
+        for (var columnIndex = 0; columnIndex < this._columns.length; columnIndex++) {
+            var existingColumn = this._columns[columnIndex];
+            if (newBlock.isThin() != existingColumn[0].isThin())
+                continue;
+
+            for (var i = 0; i < existingColumn.length; i++) {
+                var currentBlock = existingColumn[i];
+                if ((!i || existingColumn[i - 1].endRowIndex() < newBlock.startRowIndex())
+                    && newBlock.endRowIndex() < currentBlock.startRowIndex()) {
+                    existingColumn.splice(i, 0, newBlock);
+                    return;
+                }
+            }
+
+            var lastBlock = existingColumn[existingColumn.length - 1];
+            if (lastBlock.endRowIndex() < newBlock.startRowIndex()) {
+                existingColumn.push(newBlock);
+                return;
+            }
+        }
+        this._columns.push([newBlock]);
+    }
+
+    createCellsForRow(rowIndex)
+    {
+        var element = ComponentBase.createElement;
+        var link = ComponentBase.createLink;
+
+        var cells = [element('td', {class: 'stacking-block'}, '')];
+        for (var columnIndex = 0; columnIndex < this._columns.length; columnIndex++) {
+            var blocksInColumn = this._columns[columnIndex];
+            if (!rowIndex && blocksInColumn[0].startRowIndex()) {
+                cells.push(this._createEmptyStackingCell(blocksInColumn[0].startRowIndex()));
+                continue;
+            }
+            for (var i = 0; i < blocksInColumn.length; i++) {
+                var block = blocksInColumn[i];
+                if (block.startRowIndex() == rowIndex) {
+                    cells.push(block.createStackingCell());
+                    break;
+                }
+                var rowCount = i + 1 < blocksInColumn.length ? blocksInColumn[i + 1].startRowIndex() : this._rowCount;
+                var remainingRows = rowCount - block.endRowIndex() - 1;
+                if (rowIndex == block.endRowIndex() + 1 && rowIndex < rowCount)
+                    cells.push(this._createEmptyStackingCell(remainingRows));
+            }
+        }
+
+        return cells;
+    }
+
+    _createEmptyStackingCell(rowspan, content)
+    {
+        return ComponentBase.createElement('td', {rowspan: rowspan, class: 'stacking-block'}, '');
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/bar-graph-group.js b/Websites/perf.webkit.org/public/v3/components/bar-graph-group.js
new file mode 100644 (file)
index 0000000..b1057b9
--- /dev/null
@@ -0,0 +1,104 @@
+
+class BarGraphGroup {
+    constructor(formatter)
+    {
+        this._bars = [];
+        this._formatter = formatter;
+    }
+
+    addBar(value, interval)
+    {
+        var newBar = new SingleBarGraph(this);
+        this._bars.push({bar: newBar, value: value, interval: interval});
+        return newBar;
+    }
+
+    render()
+    {
+        Instrumentation.startMeasuringTime('BarGraphGroup', 'render');
+
+        var min = Infinity;
+        var max = -Infinity;
+        for (var entry of this._bars) {
+            min = Math.min(min, entry.interval ? entry.interval[0] : entry.value);
+            max = Math.max(max, entry.interval ? entry.interval[1] : entry.value);
+        }
+
+        for (var entry of this._bars) {
+            var value = entry.value;
+            var formattedValue = this._formatter(value);
+            if (entry.interval)
+                formattedValue += ' \u00B1' + ((value - entry.interval[0]) * 100 / value).toFixed(2) + '%';
+
+            var diff = (max - min);
+            var range = diff * 1.2;
+            var start = min - (range - diff) / 2;
+
+            entry.bar.update((value - start) / range, formattedValue);
+            entry.bar.render();
+        }
+
+        Instrumentation.endMeasuringTime('BarGraphGroup', 'render');
+    }
+}
+
+class SingleBarGraph extends ComponentBase {
+
+    constructor(group)
+    {
+        console.assert(group instanceof BarGraphGroup);
+        super('single-bar-graph');
+        this._percentage = 0;
+        this._label = null;
+    }
+
+    update(percentage, label)
+    {
+        this._percentage = percentage;
+        this._label = label;
+    }
+
+    render()
+    {
+        this.content().querySelector('.percentage').style.width = `calc(${this._percentage * 100}% - 2px)`;
+        this.content().querySelector('.label').textContent = this._label;
+    }
+
+    static htmlTemplate()
+    {
+        return `<div class="single-bar-graph"><div class="percentage"></div><div class="label">-</div></div>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            .single-bar-graph {
+                position: relative;
+                display: block;
+                background: #eee;
+                height: 100%;
+                overflow: hidden;
+                text-decoration: none;
+                color: black;
+            }
+            .single-bar-graph .percentage {
+                position: absolute;
+                top: 1px;
+                left: 1px;
+                background: #ccc;
+                height: calc(100% - 2px);
+            }
+            .single-bar-graph .label {
+                position: absolute;
+                top: calc(50% - 0.35rem);
+                left: 0;
+                width: 100%;
+                height: 100%;
+                font-size: 0.8rem;
+                line-height: 0.8rem;
+                text-align: center;
+            }
+        `;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/results-table.js b/Websites/perf.webkit.org/public/v3/components/results-table.js
new file mode 100644 (file)
index 0000000..46f8c6e
--- /dev/null
@@ -0,0 +1,256 @@
+class ResultsTable extends ComponentBase {
+    constructor(name)
+    {
+        super(name);
+        this._repositoryList = [];
+        this._valueFormatter = null;
+    }
+
+    setValueFormatter(valueFormatter) { this._valueFormatter = valueFormatter; }
+
+    render()
+    {
+        if (!this._valueFormatter)
+            return;
+
+        Instrumentation.startMeasuringTime('ResultsTable', 'render');
+
+        var rowGroups = this.buildRowGroups();
+
+        var extraRepositories = [];
+        var repositoryList = this._computeRepositoryList(rowGroups, extraRepositories);
+
+        var barGraphGroup = new BarGraphGroup(this._valueFormatter);
+        var element = ComponentBase.createElement;
+        var link = ComponentBase.createLink;
+        var tableBodies = rowGroups.map(function (group) {
+            var groupHeading = group.heading;
+            var revisionSupressionCount = {};
+
+            return element('tbody', group.rows.map(function (row, rowIndex) {
+                var cells = row.buildHeading(barGraphGroup);
+
+                if (groupHeading && !rowIndex)
+                    cells.unshift(element('th', {rowspan: group.rows.length}, groupHeading));
+
+                var rootSet = row.rootSet();
+                repositoryList.forEach(function (repository) {
+                    var commit = rootSet ? rootSet.commitForRepository(repository) : null;
+
+                    if (revisionSupressionCount[repository.id()]) {
+                        revisionSupressionCount[repository.id()]--;
+                        return;
+                    }
+
+                    var succeedingRowIndex = rowIndex + 1;
+                    while (succeedingRowIndex < group.rows.length) {
+                        var succeedingRootSet = group.rows[succeedingRowIndex].rootSet();
+                        if (succeedingRootSet && commit != succeedingRootSet.commitForRepository(repository))
+                            break;
+                        succeedingRowIndex++;
+                    }
+                    var rowSpan = succeedingRowIndex - rowIndex;
+                    var attributes = {class: 'revision'};
+                    if (rowSpan > 1) {
+                        revisionSupressionCount[repository.id()] = rowSpan - 1;
+                        attributes['rowspan'] = rowSpan;                       
+                    }
+                    if (rowIndex + rowSpan >= group.rows.length)
+                        attributes['class'] += ' lastRevision';
+
+                    var content = 'Missing';
+                    if (commit) {
+                        var url = commit.url();
+                        content = url ? link(commit.label(), url) : commit.label();
+                    }
+
+                    cells.push(element('td', attributes, content));
+                });
+
+                return element('tr', [cells, row.additionalColumns()]);
+            }));
+        });
+
+        this.renderReplace(this.content().querySelector('table'), [
+            element('thead', [
+                this.heading(),
+                element('th', 'Result'),
+                repositoryList.map(function (repository) { return element('th', repository.label()); }),
+                this.additionalHeading(),
+            ]),
+            tableBodies,
+        ]);
+
+        this.renderReplace(this.content().querySelector('.results-table-extra-repositories'),
+            extraRepositories.map(function (commit) { return element('li', commit.title()); }));
+
+        barGraphGroup.render();
+
+        Instrumentation.endMeasuringTime('ResultsTable', 'render');
+    }
+
+    heading() { throw 'NotImplemented'; }
+    additionalHeading() { return []; }
+    buildRowGroups() { throw 'NotImplemented'; }
+
+    _computeRepositoryList(rowGroups, extraRepositories)
+    {
+        var allRepositories = Repository.all().sort(function (a, b) {
+            if (a.hasUrlForRevision() == b.hasUrlForRevision()) {
+                if (a.name() > b.name())
+                    return 1;
+                if (a.name() < b.name())
+                    return -1;
+                return 0;
+            }
+            return a.hasUrlForRevision() ? -1 /* a < b */ : 1; // a > b
+        });
+        var rootSets = [];
+        for (var group of rowGroups) {
+            for (var row of group.rows) {
+                var rootSet = row.rootSet();
+                if (rootSet)
+                    rootSets.push(rootSet);
+            }
+        }
+        if (!rootSets.length)
+            return [];
+
+        var repositoryPresenceMap = {};
+        for (var repository of allRepositories) {
+            var someCommit = rootSets[0].commitForRepository(repository);
+            if (RootSet.containsMultipleCommitsForRepository(rootSets, repository))
+                repositoryPresenceMap[repository.id()] = true;
+            else if (someCommit)
+                extraRepositories.push(someCommit);
+        }
+        return allRepositories.filter(function (repository) { return repositoryPresenceMap[repository.id()]; });
+    }
+
+    static htmlTemplate()
+    {
+        return `<table class="results-table"></table><ul class="results-table-extra-repositories"></ul>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            table.results-table {
+                border-collapse: collapse;
+                border: solid 0px #ccc;
+                font-size: 0.9rem;
+            }
+
+            .results-table th {
+                text-align: center;
+                font-weight: inherit;
+                font-size: 1rem;
+                padding: 0.2rem;
+            }
+
+            .results-table td {
+                height: 1.4rem;
+                text-align: center;
+            }
+            
+            .results-table thead {
+                color: #c93;
+            }
+
+            .results-table td.revision {
+                vertical-align: top;
+                position: relative;
+            }
+
+            .results-table tr:not(:last-child) td.revision:after {
+                display: block;
+                content: '';
+                position: absolute;
+                left: calc(50% - 0.25rem);
+                bottom: -0.4rem;
+                border-style: solid;
+                border-width: 0.25rem;
+                border-color: #999 transparent transparent transparent;
+            }
+
+            .results-table tr:not(:last-child) td.revision:before {
+                display: block;
+                content: '';
+                position: absolute;
+                left: calc(50% - 1px);
+                top: 1.2rem;
+                height: calc(100% - 1.4rem);
+                border: solid 1px #999;
+            }
+            
+            .results-table tr:not(:last-child) td.revision.lastRevision:after {
+                bottom: -0.2rem;
+            }
+            .results-table tr:not(:last-child) td.revision.lastRevision:before {
+                height: calc(100% - 1.6rem);
+            }
+
+            .results-table tbody:not(:first-child) tr:first-child th,
+            .results-table tbody:not(:first-child) tr:first-child td {
+                border-top: solid 1px #ccc;
+            }
+
+            .results-table single-bar-graph {
+                display: block;
+                width: 7rem;
+                height: 1.2rem;
+            }
+
+            .results-table-extra-repositories {
+                list-style: none;
+                margin: 0;
+                padding: 0.5rem 0 0 0.5rem;
+                font-size: 0.8rem;
+            }
+
+            .results-table-extra-repositories li {
+                display: inline;
+            }
+
+            .results-table-extra-repositories li:not(:last-child):after {
+                content: ', ';
+            }
+        `;
+    }
+}
+
+class ResultsTableRow {
+    constructor(heading, rootSet)
+    {
+        this._heading = heading;
+        this._result = null;
+        this._link = null;
+        this._label = '-';
+        this._rootSet = rootSet;
+        this._additionalColumns = [];
+    }
+
+    rootSet() { return this._rootSet; }
+
+    setResult(result) { this._result = result; }
+    setLink(link, label)
+    {
+        this._link = link;
+        this._label = label;
+    }
+
+    additionalColumns() { return this._additionalColumns; }
+    setAdditionalColumns(additionalColumns) { this._additionalColumns = additionalColumns; }
+
+    buildHeading(barGraphGroup)
+    {
+        var element = ComponentBase.createElement;
+        var link = ComponentBase.createLink;
+
+        var resultContent = this._result ? barGraphGroup.addBar(this._result.value, this._result.interval) : this._label;
+        return [
+            element('th', this._heading),
+            element('td', this._link ? link(resultContent, this._label, this._link) : resultContent),
+        ];
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js b/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js
new file mode 100644 (file)
index 0000000..f1db25b
--- /dev/null
@@ -0,0 +1,57 @@
+
+class TestGroupResultsTable extends ResultsTable {
+    constructor()
+    {
+        super('test-group-results-table');
+        this._testGroup = null;
+        this._renderedTestGroup = null;
+    }
+
+    didUpdateResults() { this._renderedTestGroup = null; }
+    setTestGroup(testGroup) { this._testGroup = testGroup; }
+
+    heading()
+    {
+        return ComponentBase.createElement('th', {colspan: 2}, 'Configuration');
+    }
+
+    render()
+    {
+        if (this._renderedTestGroup == this._testGroup)
+            return;
+        this._renderedTestGroup = this._testGroup;
+        super.render();
+    }
+
+    buildRowGroups()
+    {
+        var testGroup = this._testGroup;
+        if (!testGroup)
+            return [];
+
+        return this._testGroup.requestedRootSets().map(function (rootSet, setIndex) {
+            var rows = [new ResultsTableRow('Mean', rootSet)];
+            var results = [];
+
+            for (var request of testGroup.requestsForRootSet(rootSet)) {
+                var result = request.result();
+                var row = new ResultsTableRow(1 + +request.order(), result ? result.rootSet() : null);
+                rows.push(row);
+                if (result) {
+                    row.setLink(result.build().url(), result.build().label());
+                    row.setResult(result);
+                    results.push(result);
+                } else
+                    row.setLink(request.statusUrl(), request.statusLabel());
+            }
+
+            var aggregatedResult = MeasurementAdaptor.aggregateAnalysisResults(results);
+            if (!isNaN(aggregatedResult.value))
+                rows[0].setResult(aggregatedResult);
+
+            return {heading: String.fromCharCode('A'.charCodeAt(0) + setIndex), rows:rows};
+        })
+    }
+}
+
+ComponentBase.defineElement('test-group-results-table', TestGroupResultsTable);
index 3297eb6..d31f673 100644 (file)
@@ -43,10 +43,12 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="instrumentation.js"></script>
         <script src="remote.js"></script>
 
+        <script src="models/measurement-adaptor.js"></script>
         <script src="models/measurement-cluster.js"></script>
         <script src="models/measurement-set.js"></script>
-        <script src="models/commit-log.js"></script>
+        <script src="models/analysis-results.js"></script>
         <script src="models/data-model.js"></script>
+        <script src="models/commit-log.js"></script>
         <script src="models/platform.js"></script>
         <script src="models/builder.js"></script>
         <script src="models/test.js"></script>
@@ -55,6 +57,9 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="models/bug-tracker.js"></script>
         <script src="models/bug.js"></script>
         <script src="models/analysis-task.js"></script>
+        <script src="models/test-group.js"></script>
+        <script src="models/build-request.js"></script>
+        <script src="models/root-set.js"></script>
 
         <script src="components/base.js"></script>
         <script src="components/spinner-icon.js"></script>
@@ -65,6 +70,10 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/interactive-time-series-chart.js"></script>
         <script src="components/chart-status-view.js"></script>
         <script src="components/pane-selector.js"></script>
+        <script src="components/bar-graph-group.js"></script>
+        <script src="components/results-table.js"></script>
+        <script src="components/analysis-results-viewer.js"></script>
+        <script src="components/test-group-results-table.js"></script>
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
         <script src="pages/heading.js"></script>
diff --git a/Websites/perf.webkit.org/public/v3/models/analysis-results.js b/Websites/perf.webkit.org/public/v3/models/analysis-results.js
new file mode 100644 (file)
index 0000000..b364cb8
--- /dev/null
@@ -0,0 +1,43 @@
+
+class AnalysisResults {
+    constructor()
+    {
+        this._buildToMetricsMap = {};
+    }
+
+    find(buildId, metric)
+    {
+        var map = this._buildToMetricsMap[buildId];
+        if (!map)
+            return null;
+        return map[metric.id()];
+    }
+
+    add(measurement)
+    {
+        console.assert(measurement.configType == 'current');
+        if (!this._buildToMetricsMap[measurement.buildId])
+            this._buildToMetricsMap[measurement.buildId] = {};
+        var map = this._buildToMetricsMap[measurement.buildId];
+        console.assert(!map[measurement.metricId]);
+        map[measurement.metricId] = measurement;
+    }
+
+    static fetch(taskId)
+    {
+        taskId = parseInt(taskId);
+        return getJSONWithStatus(`../api/measurement-set?analysisTask=${taskId}`).then(function (response) {
+
+            Instrumentation.startMeasuringTime('AnalysisResults', 'fetch');
+
+            var adaptor = new MeasurementAdaptor(response['formatMap']);
+            var results = new AnalysisResults;
+            for (var rawMeasurement of response['measurements'])
+                results.add(adaptor.adoptToAnalysisResults(rawMeasurement));
+
+            Instrumentation.endMeasuringTime('AnalysisResults', 'fetch');
+
+            return results;
+        });
+    }
+}
index 274b2f7..6bb8455 100644 (file)
@@ -33,7 +33,9 @@ class AnalysisTask extends LabeledObject {
     hasPendingRequests() { return this._finishedBuildRequestCount < this._buildRequestCount; }
     requestLabel() { return `${this._finishedBuildRequestCount} of ${this._buildRequestCount}`; }
 
+    startMeasurementId() { return this._startMeasurementId; }
     startTime() { return this._startTime; }
+    endMeasurementId() { return this._endMeasurementId; }
     endTime() { return this._endTime; }
 
     author() { return this._author || ''; }
@@ -59,6 +61,11 @@ class AnalysisTask extends LabeledObject {
         return this._fetchSubset({id: id}).then(function (data) { return AnalysisTask.findById(id); });
     }
 
+    static fetchByBuildRequestId(id)
+    {
+        return this._fetchSubset({buildRequest: id}).then(function (tasks) { return tasks[0]; });
+    }
+
     static fetchByPlatformAndMetric(platformId, metricId)
     {
         return this._fetchSubset({platform: platformId, metric: metricId}).then(function (data) {
@@ -70,24 +77,7 @@ class AnalysisTask extends LabeledObject {
     {
         if (this._fetchAllPromise)
             return this._fetchAllPromise;
-
-        var query = [];
-        for (var key in params)
-            query.push(key + '=' + parseInt(params[key]));
-        var queryString = query.join('&');
-
-        if (!this._fetchSubsetPromises)
-            this._fetchSubsetPromises = {};
-        else {
-            var existingPromise = this._fetchSubsetPromises[queryString];
-            if (existingPromise)
-                return existingPromise;
-        }
-
-        var newPromise = getJSONWithStatus('../api/analysis-tasks?' + queryString).then(this._constructAnalysisTasksFromRawData.bind(this));
-        this._fetchSubsetPromises[queryString] = newPromise;
-
-        return newPromise;
+        return this.cachedFetch('../api/analysis-tasks', params).then(this._constructAnalysisTasksFromRawData.bind(this));
     }
 
     static fetchAll()
diff --git a/Websites/perf.webkit.org/public/v3/models/build-request.js b/Websites/perf.webkit.org/public/v3/models/build-request.js
new file mode 100644 (file)
index 0000000..63b01fc
--- /dev/null
@@ -0,0 +1,48 @@
+
+class BuildRequest extends DataModelObject {
+
+    constructor(id, object)
+    {
+        super(id, object);
+        console.assert(object.testGroup instanceof TestGroup);
+        this._testGroup = object.testGroup;
+        this._testGroup.addBuildRequest(this);
+        this._order = object.order;
+        console.assert(object.rootSet instanceof RootSet);
+        this._rootSet = object.rootSet;
+        this._status = object.status;
+        this._statusUrl = object.url;
+        this._buildId = object.build;
+        this._result = null;
+    }
+
+    testGroup() { return this._testGroup; }
+    order() { return this._order; }
+    rootSet() { return this._rootSet; }
+
+    statusLabel()
+    {
+        switch (this._status) {
+        case 'pending':
+            return 'Waiting to be scheduled';
+        case 'scheduled':
+            return 'Scheduled';
+        case 'running':
+            return 'Running';
+        case 'failed':
+            return 'Failed';
+        case 'completed':
+            return 'Completed';
+        }
+    }
+    statusUrl() { return this._statusUrl; }
+
+    buildId() { return this._buildId; }
+
+    result() { return this._result; }
+    setResult(result)
+    {
+        this._result = result;
+        this._testGroup.didSetResult(this);
+    }
+}
index 1de00db..1fe66a9 100644 (file)
@@ -13,3 +13,16 @@ class Builder extends LabeledObject {
         return this._buildUrlTemplate.replace(/\$builderName/g, this.name()).replace(/\$buildNumber/g, buildNumber);
     }
 }
+
+class Build extends DataModelObject {
+    constructor(id, builder, buildNumber)
+    {
+        console.assert(builder instanceof Builder);
+        super(id);
+        this._builder = builder;
+        this._buildNumber = buildNumber;
+    }
+
+    label() { return `Build ${this._buildNumber} on ${this._builder.label()}`; }
+    url() { return this._builder.urlForBuild(this._buildNumber); }
+}
index 86bebab..8476869 100644 (file)
@@ -1,11 +1,27 @@
 
-class CommitLog {
-    constructor(repository, rawData)
+class CommitLog extends DataModelObject {
+    constructor(id, repository, rawData)
     {
+        super(id);
         this._repository = repository;
         this._rawData = rawData;
     }
 
+    static ensureSingleton(repository, rawData)
+    {
+        var id = repository.id() + '-' + rawData['revision'];
+        var singleton = this.findById(id);
+        if (singleton) {
+            if (rawData.authorName)
+                singleton._rawData.authorName = rawData.authorName;
+            if (rawData.message)
+                singleton._rawData.message = rawData.message;
+            return singleton;
+        }
+        return new CommitLog(id, repository, rawData);
+    }
+
+    repository() { return this._repository; }
     time() { return new Date(this._rawData['time']); }
     author() { return this._rawData['authorName']; }
     revision() { return this._rawData['revision']; }
@@ -42,7 +58,7 @@ class CommitLog {
 
         var self = this;
         return getJSONWithStatus(url).then(function (data) {
-            var commits = data['commits'].map(function (rawData) { return new CommitLog(repository, rawData); });
+            var commits = data['commits'].map(function (rawData) { return CommitLog.ensureSingleton(repository, rawData); });
             self._cacheCommitLogs(repository, from, to, commits);
             return commits;
         });
index aa610b4..9995a51 100644 (file)
@@ -3,27 +3,29 @@ class DataModelObject {
     constructor(id)
     {
         this._id = id;
-        this.namedStaticMap('id')[id] = this;
+        this.ensureNamedStaticMap('id')[id] = this;
     }
     id() { return this._id; }
 
-    namedStaticMap(name)
+    static namedStaticMap(name)
     {
-        var newTarget = this.__proto__.constructor;
-        if (!newTarget[DataModelObject.StaticMapSymbol])
-            newTarget[DataModelObject.StaticMapSymbol] = {};
-        var staticMap = newTarget[DataModelObject.StaticMapSymbol];
-        if (!staticMap[name])
-            staticMap[name] = [];
-        return staticMap[name];
+        var staticMap = this[DataModelObject.StaticMapSymbol];
+        return staticMap ? staticMap[name] : null;
     }
 
-    static namedStaticMap(name)
+    static ensureNamedStaticMap(name)
     {
+        if (!this[DataModelObject.StaticMapSymbol])
+            this[DataModelObject.StaticMapSymbol] = {};
         var staticMap = this[DataModelObject.StaticMapSymbol];
-        return staticMap ? staticMap[name] : null;
+        if (!staticMap[name])
+            staticMap[name] = [];
+        return staticMap[name];
     }
 
+    namedStaticMap(name) { return this.__proto__.constructor.namedStaticMap(name); }
+    ensureNamedStaticMap(name) { return this.__proto__.constructor.ensureNamedStaticMap(name); }
+
     static findById(id)
     {
         var idMap = this.namedStaticMap('id');
@@ -40,15 +42,34 @@ class DataModelObject {
         }
         return list;
     }
+
+    static cachedFetch(path, params)
+    {
+        var query = [];
+        if (params) {
+            for (var key in params)
+                query.push(key + '=' + parseInt(params[key]));
+        }
+        if (query.length)
+            path += '?' + query.join('&');
+
+        var cacheMap = this.ensureNamedStaticMap(DataModelObject.CacheMapSymbol);
+        if (!cacheMap[path])
+            cacheMap[path] = getJSONWithStatus(path);
+
+        return cacheMap[path];
+    }
+
 }
 DataModelObject.StaticMapSymbol = Symbol();
+DataModelObject.CacheMapSymbol = Symbol();
 
 class LabeledObject extends DataModelObject {
     constructor(id, object)
     {
         super(id);
         this._name = object.name;
-        this.namedStaticMap('name')[this._name] = this;
+        this.ensureNamedStaticMap('name')[this._name] = this;
     }
 
     static findByName(name)
diff --git a/Websites/perf.webkit.org/public/v3/models/measurement-adaptor.js b/Websites/perf.webkit.org/public/v3/models/measurement-adaptor.js
new file mode 100644 (file)
index 0000000..3eb489f
--- /dev/null
@@ -0,0 +1,123 @@
+
+class MeasurementAdaptor {
+    constructor(formatMap)
+    {
+        var nameMap = {};
+        formatMap.forEach(function (key, index) {
+            nameMap[key] = index;
+        });
+        this._idIndex = nameMap['id'];
+        this._commitTimeIndex = nameMap['commitTime'];
+        this._countIndex = nameMap['iterationCount'];
+        this._meanIndex = nameMap['mean'];
+        this._sumIndex = nameMap['sum'];
+        this._squareSumIndex = nameMap['squareSum'];
+        this._markedOutlierIndex = nameMap['markedOutlier'];
+        this._revisionsIndex = nameMap['revisions'];
+        this._buildIndex = nameMap['build'];
+        this._buildTimeIndex = nameMap['buildTime'];
+        this._buildNumberIndex = nameMap['buildNumber'];
+        this._builderIndex = nameMap['builder'];
+        this._metricIndex = nameMap['metric'];
+        this._configTypeIndex = nameMap['configType'];
+    }
+
+    extractId(row)
+    {
+        return row[this._idIndex];
+    }
+
+    adoptToAnalysisResults(row)
+    {
+        var id = row[this._idIndex];
+        var mean = row[this._meanIndex];
+        var sum = row[this._sumIndex];
+        var squareSum = row[this._squareSumIndex];
+        var iterationCount = row[this._countIndex];
+        var revisionList = row[this._revisionsIndex];
+        var buildId = row[this._buildIndex];
+        var metricId = row[this._metricIndex];
+        var configType = row[this._configTypeIndex];
+        var self = this;
+        return {
+            id: id,
+            buildId: buildId,
+            metricId: metricId,
+            configType: configType,
+            rootSet: function () { return MeasurementRootSet.ensureSingleton(id, revisionList); },
+            build: function () {
+                var builder = Builder.findById(row[self._builderIndex]);
+                return new Build(id, builder, row[self._buildNumberIndex]);
+            },
+            value: mean,
+            sum: sum,
+            squareSum,
+            iterationCount: iterationCount,
+            interval: MeasurementAdaptor.computeConfidenceInterval(row[this._countIndex], mean, sum, squareSum)
+        };
+    }
+
+    static aggregateAnalysisResults(results)
+    {
+        var totalSum = 0;
+        var totalSquareSum = 0;
+        var totalIterationCount = 0;
+        var means = [];
+        for (var result of results) {
+            means.push(result.value);
+            totalSum += result.sum;
+            totalSquareSum += result.squareSum;
+            totalIterationCount += result.iterationCount;
+        }
+        var mean = totalSum / totalIterationCount;
+        var interval;
+        try {
+            interval = this.computeConfidenceInterval(totalIterationCount, mean, totalSum, totalSquareSum)
+        } catch (error) {
+            interval = this.computeConfidenceInterval(results.length, mean, Statistics.sum(means), Statistics.squareSum(means));
+        }
+        return { value: mean, interval: interval };
+    }
+
+    adoptToSeries(row, series, seriesIndex)
+    {
+        var id = row[this._idIndex];
+        var mean = row[this._meanIndex];
+        var sum = row[this._sumIndex];
+        var squareSum = row[this._squareSumIndex];
+        var revisionList = row[this._revisionsIndex];
+        var self = this;
+        return {
+            id: id,
+            measurement: function () {
+                // Create a new Measurement class that doesn't require mimicking what runs.php generates.
+                var revisionsMap = {};
+                for (var revisionRow of revisionList)
+                    revisionsMap[revisionRow[0]] = revisionRow.slice(1);
+                return new Measurement({
+                    id: id,
+                    mean: mean,
+                    sum: sum,
+                    squareSum: squareSum,
+                    revisions: revisionsMap,
+                    build: row[self._buildIndex],
+                    buildTime: row[self._buildTimeIndex],
+                    buildNumber: row[self._buildNumberIndex],
+                    builder: row[self._builderIndex],
+                });
+            },
+            rootSet: function () { return MeasurementRootSet.ensureSingleton(id, revisionList); },
+            series: series,
+            seriesIndex: seriesIndex,
+            time: row[this._commitTimeIndex],
+            value: mean,
+            interval: MeasurementAdaptor.computeConfidenceInterval(row[this._countIndex], mean, sum, squareSum)
+        };
+    }
+
+    static computeConfidenceInterval(iterationCount, mean, sum, squareSum)
+    {
+        var delta = Statistics.confidenceIntervalDelta(0.95, iterationCount, sum, squareSum);
+        return isNaN(delta) ? null : [mean - delta, mean + delta];
+    }
+}
index 45b007f..8bad89e 100644 (file)
@@ -3,25 +3,7 @@ class MeasurementCluster {
     constructor(response)
     {
         this._response = response;
-        this._idMap = {};
-
-        var nameMap = {};
-        response['formatMap'].forEach(function (key, index) {
-            nameMap[key] = index;
-        });
-
-        this._idIndex = nameMap['id'];
-        this._commitTimeIndex = nameMap['commitTime'];
-        this._countIndex = nameMap['iterationCount'];
-        this._meanIndex = nameMap['mean'];
-        this._sumIndex = nameMap['sum'];
-        this._squareSumIndex = nameMap['squareSum'];
-        this._markedOutlierIndex = nameMap['markedOutlier'];
-        this._revisionsIndex = nameMap['revisions'];
-        this._buildIndex = nameMap['build'];
-        this._buildTimeIndex = nameMap['buildTime'];
-        this._buildNumberIndex = nameMap['buildNumber'];
-        this._builderIndex = nameMap['builder'];
+        this._adaptor = new MeasurementAdaptor(response['formatMap']);
     }
 
     startTime() { return this._response['startTime']; }
@@ -34,7 +16,7 @@ class MeasurementCluster {
 
         var self = this;
         rawMeasurements.forEach(function (row) {
-            var id = row[self._idIndex];
+            var id = self._adaptor.extractId(row);
             if (id in idMap)
                 return;
             if (row[self._markedOutlierIndex] && !includeOutliers)
@@ -42,41 +24,7 @@ class MeasurementCluster {
 
             idMap[id] = true;
 
-            var mean = row[self._meanIndex];
-            var sum = row[self._sumIndex];
-            var squareSum = row[self._squareSumIndex];
-            series._series.push({
-                id: id,
-                _rawMeasurement: row,
-                measurement: function () {
-                    // Create a new Measurement class that doesn't require mimicking what runs.php generates.
-                    var revisionsMap = {};
-                    for (var revisionRow of row[self._revisionsIndex])
-                        revisionsMap[revisionRow[0]] = revisionRow.slice(1);
-                    return new Measurement({
-                        id: id,
-                        mean: mean,
-                        sum: sum,
-                        squareSum: squareSum,
-                        revisions: revisionsMap,
-                        build: row[self._buildIndex],
-                        buildTime: row[self._buildTimeIndex],
-                        buildNumber: row[self._buildNumberIndex],
-                        builder: row[self._builderIndex],
-                    });
-                },
-                series: series,
-                seriesIndex: series._series.length,
-                time: row[self._commitTimeIndex],
-                value: mean,
-                interval: self._computeConfidenceInterval(row[self._countIndex], mean, sum, squareSum)
-            });            
+            series._series.push(self._adaptor.adoptToSeries(row, series, series._series.length));
         });
     }
-
-    _computeConfidenceInterval(iterationCount, mean, sum, squareSum)
-    {
-        var delta = Statistics.confidenceIntervalDelta(0.95, iterationCount, sum, squareSum);
-        return isNaN(delta) ? null : [mean - delta, mean + delta];
-    }
 }
index 17aef30..0b4c753 100644 (file)
@@ -8,6 +8,8 @@ class Repository extends LabeledObject {
         this._hasReportedCommits = object.hasReportedCommits;
     }
 
+    hasUrlForRevision() { return !!this._url; }
+
     urlForRevision(currentRevision)
     {
         return (this._url || '').replace(/\$1/g, currentRevision);
diff --git a/Websites/perf.webkit.org/public/v3/models/root-set.js b/Websites/perf.webkit.org/public/v3/models/root-set.js
new file mode 100644 (file)
index 0000000..e4c2e1d
--- /dev/null
@@ -0,0 +1,83 @@
+
+class RootSet extends DataModelObject {
+
+    constructor(id, object)
+    {
+        super(id);
+        this._repositories = [];
+        this._repositoryToCommitMap = {};
+        this._latestCommitTime = null;
+
+        if (!object)
+            return;
+
+        for (var row of object.roots) {
+            var repositoryId = row.repository;
+            var repository = Repository.findById(repositoryId);
+
+            console.assert(!this._repositoryToCommitMap[repositoryId]);
+            this._repositoryToCommitMap[repositoryId] = CommitLog.ensureSingleton(repository, row);
+            this._repositories.push(repository);
+        }
+    }
+
+    repositories() { return this._repositories; }
+    commitForRepository(repository) { return this._repositoryToCommitMap[repository.id()]; }
+
+    latestCommitTime()
+    {
+        if (this._latestCommitTime == null) {
+            var maxTime = 0;
+            for (var repositoryId in this._repositoryToCommitMap)
+                maxTime = Math.max(maxTime, +this._repositoryToCommitMap[repositoryId].time());
+            this._latestCommitTime = maxTime;
+        }
+        return this._latestCommitTime;
+    }
+
+    equals(other)
+    {
+        if (this._repositories.length != other._repositories.length)
+            return false;
+        for (var repositoryId in this._repositoryToCommitMap) {
+            if (this._repositoryToCommitMap[repositoryId] != other._repositoryToCommitMap[repositoryId])
+                return false;
+        }
+        return true;
+    }
+
+    static containsMultipleCommitsForRepository(rootSets, repository)
+    {
+        console.assert(repository instanceof Repository);
+        if (rootSets.length < 2)
+            return false;
+        var firstCommit = rootSets[0].commitForRepository(repository);
+        for (var set of rootSets) {
+            var anotherCommit = set.commitForRepository(repository);
+            if (!firstCommit != !anotherCommit || (firstCommit && firstCommit.revision() != anotherCommit.revision()))
+                return true;
+        }
+        return false;
+    }
+}
+
+class MeasurementRootSet extends RootSet {
+
+    constructor(id, revisionList)
+    {
+        super(id, null);
+        for (var values of revisionList) {
+            var repositoryId = values[0];
+            var repository = Repository.findById(repositoryId);
+
+            this._repositoryToCommitMap[repositoryId] = CommitLog.ensureSingleton(repository, {revision: values[1], time: values[2]});
+            this._repositories.push(repository);
+        }
+    }
+
+    static ensureSingleton(measurementId, revisionList)
+    {
+        var rootSetId = measurementId + '-rootset';
+        return RootSet.findById(rootSetId) || (new MeasurementRootSet(rootSetId, revisionList));
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/test-group.js b/Websites/perf.webkit.org/public/v3/models/test-group.js
new file mode 100644 (file)
index 0000000..5ecab8f
--- /dev/null
@@ -0,0 +1,90 @@
+
+class TestGroup extends LabeledObject {
+
+    constructor(id, object)
+    {
+        super(id, object);
+        this._taskId = object.task;
+        this._authorName = object.author;
+        this._createdAt = new Date(object.createdAt);
+        this._buildRequests = [];
+        this._requestsAreInOrder = false;
+        this._repositories = null;
+        this._requestedRootSets = null;
+        this._allRootSets = null;
+        console.assert(!object.platform || object.platform instanceof Platform);
+        this._platform = object.platform;
+    }
+
+    createdAt() { return this._createdAt; }
+    buildRequests() { return this._buildRequests; }
+    addBuildRequest(request)
+    {
+        this._buildRequests.push(request);
+        this._requestsAreInOrder = false;
+        this._requestedRootSets = null;
+    }
+
+    requestedRootSets()
+    {
+        if (!this._requestedRootSets) {
+            this._orderBuildRequests();
+            this._requestedRootSets = [];
+            for (var request of this._buildRequests) {
+                var set = request.rootSet();
+                if (!this._requestedRootSets.includes(set))
+                    this._requestedRootSets.push(set);
+            }
+            this._requestedRootSets.sort(function (a, b) { return a.latestCommitTime() - b.latestCommitTime(); });
+        }
+        return this._requestedRootSets;
+    }
+
+    requestsForRootSet(rootSet)
+    {
+        this._orderBuildRequests();
+        return this._buildRequests.filter(function (request) { return request.rootSet() == rootSet; });
+    }
+
+    _orderBuildRequests()
+    {
+        if (this._requestsAreInOrder)
+            return;
+        this._buildRequests = this._buildRequests.sort(function (a, b) { return a.order() - b.order(); });
+        this._requestsAreInOrder = true;
+    }
+
+    didSetResult(request)
+    {
+        this._allRootSets = null;
+    }
+
+    static fetchByTask(taskId)
+    {
+        return this.cachedFetch('../api/test-groups', {task: taskId}).then(function (data) {
+            var testGroups = data['testGroups'].map(function (row) {
+                row.platform = Platform.findById(row.platform);
+                return new TestGroup(row.id, row);
+            });
+
+            var rootIdMap = {};
+            for (var root of data['roots'])
+                rootIdMap[root.id] = root;
+
+            var rootSets = data['rootSets'].map(function (row) {
+                row.roots = row.roots.map(function (rootId) { return rootIdMap[rootId]; });
+                row.testGroup = RootSet.findById(row.testGroup);
+                return new RootSet(row.id, row);
+            });
+
+            var buildRequests = data['buildRequests'].map(function (rawData) {
+                rawData.testGroup = TestGroup.findById(rawData.testGroup);
+                rawData.rootSet = RootSet.findById(rawData.rootSet);
+                return new BuildRequest(rawData.id, rawData);
+            });
+
+            return testGroups;
+        });
+    }
+
+}
index b2de0ad..f172adb 100644 (file)
@@ -9,7 +9,7 @@ class Test extends LabeledObject {
         this._metrics = [];
 
         if (isTopLevel)
-            this.namedStaticMap('topLevelTests').push(this);
+            this.ensureNamedStaticMap('topLevelTests').push(this);
     }
 
     static topLevelTests() { return this.sortByName(this.namedStaticMap('topLevelTests')); }
index b80aa63..41e026f 100644 (file)
@@ -5,7 +5,18 @@ class AnalysisTaskPage extends PageWithHeading {
         super('Analysis Task');
         this._taskId = null;
         this._task = null;
+        this._testGroups = null;
+        this._renderedTestGroups = null;
+        this._renderedCurrentTestGroup = null;
+        this._analysisResults = null;
+        this._measurementSet = null;
+        this._startPoint = null;
+        this._endPoint = null;
         this._errorMessage = null;
+        this._currentTestGroup = null;
+        this._analysisResultsViewer = this.content().querySelector('analysis-results-viewer').component();
+        this._analysisResultsViewer.setTestGroupCallback(this._showTestGroup.bind(this));
+        this._testGroupResultsTable = this.content().querySelector('test-group-results-table').component();
     }
 
     title() { return this._task ? this._task.label() : 'Analysis Task'; }
@@ -13,17 +24,96 @@ class AnalysisTaskPage extends PageWithHeading {
 
     updateFromSerializedState(state)
     {
-        var taskId = parseInt(state.remainingRoute);
-        if (taskId != state.remainingRoute) {
-            this._errorMessage = `Invalid analysis task ID: ${state.remainingRoute}`;
+        var self = this;
+        if (state.remainingRoute) {
+            this._taskId = parseInt(state.remainingRoute);
+            AnalysisTask.fetchById(this._taskId).then(this._didFetchTask.bind(this), function (error) {
+                self._errorMessage = `Failed to fetch the analysis task ${state.remainingRoute}: ${error}`;
+                self.render();
+            });
+            TestGroup.fetchByTask(this._taskId).then(this._didFetchTestGroups.bind(this));
+            AnalysisResults.fetch(this._taskId).then(this._didFetchAnalysisResults.bind(this));
+        } else if (state.buildRequest) {
+            var buildRequestId = parseInt(state.buildRequest);
+            AnalysisTask.fetchByBuildRequestId(buildRequestId).then(this._didFetchTask.bind(this)).then(function () {
+                if (self._task) {
+                    TestGroup.fetchByTask(self._task.id()).then(self._didFetchTestGroups.bind(self));
+                    AnalysisResults.fetch(self._task.id()).then(this._didFetchAnalysisResults.bind(this));
+                }
+            }, function (error) {
+                self._errorMessage = `Failed to fetch the analysis task for the build request ${buildRequestId}: ${error}`;
+                self.render();
+            });
+        }
+    }
+
+    _didFetchTask(task)
+    {
+        this._task = task;
+        var platform = task.platform();
+        var metric = task.metric();
+        var lastModified = platform.lastModified(metric);
+
+        this._measurementSet = MeasurementSet.findSet(platform.id(), metric.id(), lastModified);
+        this._measurementSet.fetchBetween(task.startTime(), task.endTime(), this._didFetchMeasurement.bind(this));
+
+        var formatter = metric.makeFormatter(4);
+        this._analysisResultsViewer.setValueFormatter(formatter);
+        this._analysisResultsViewer.setSmallerIsBetter(metric.isSmallerBetter());
+        this._testGroupResultsTable.setValueFormatter(formatter);
+
+        this.render();
+    }
+
+    _didFetchMeasurement()
+    {
+        console.assert(this._task);
+        console.assert(this._measurementSet);
+        var series = this._measurementSet.fetchedTimeSeries('current', false, false);
+        var startPoint = series.findById(this._task.startMeasurementId());
+        var endPoint = series.findById(this._task.endMeasurementId());
+        if (!startPoint || !endPoint)
             return;
+
+        this._analysisResultsViewer.setPoints(startPoint, endPoint);
+
+        this._startPoint = startPoint;
+        this._endPoint = endPoint;
+        this.render();
+    }
+
+    _didFetchTestGroups(testGroups)
+    {
+        this._testGroups = testGroups.sort(function (a, b) { return +a.createdAt() - b.createdAt(); });
+        this._currentTestGroup = testGroups.length ? testGroups[0] : null;
+
+        this._analysisResultsViewer.setTestGroups(testGroups);
+        this._testGroupResultsTable.setTestGroup(this._currentTestGroup);
+        this._assignTestResultsIfPossible();
+        this.render();
+    }
+
+    _didFetchAnalysisResults(results)
+    {
+        this._analysisResults = results;
+        if (this._assignTestResultsIfPossible())
+            this.render();
+    }
+
+    _assignTestResultsIfPossible()
+    {
+        if (!this._task || !this._testGroups || !this._analysisResults)
+            return false;
+
+        for (var group of this._testGroups) {
+            for (var request of group.buildRequests())
+                request.setResult(this._analysisResults.find(request.buildId(), this._task.metric()));
         }
 
-        var self = this;
-        AnalysisTask.fetchById(taskId).then(function (task) {
-            self._task = task;
-            self.render();
-        });
+        this._analysisResultsViewer.didUpdateResults();
+        this._testGroupResultsTable.didUpdateResults();
+
+        return true;
     }
 
     render()
@@ -34,28 +124,93 @@ class AnalysisTaskPage extends PageWithHeading {
 
         this.content().querySelector('.error-message').textContent = this._errorMessage || '';
 
-        var v2URL = location.href.replace('/v3/', '/v2/');
-        this.content().querySelector('.overview-chart').innerHTML = `Not ready. Use <a href="${v2URL}">v2 page</a> for now.`;
+        var v2URL = `/v2/#/analysis/task/${this._taskId}`;
+        this.content().querySelector('.error-message').innerHTML +=
+            `<p>This page is read only for now. To schedule a new A/B testing job, use <a href="${v2URL}">v2 page</a>.</p>`;
 
         if (this._task) {
             this.renderReplace(this.content().querySelector('.analysis-task-name'), this._task.name());
+            var platform = this._task.platform();
+            var metric = this._task.metric();
+            var anchor = this.content().querySelector('.platform-metric-names a');
+            this.renderReplace(anchor, metric.fullName() + ' on ' + platform.label());
+            anchor.href = this.router().url('charts', ChartsPage.createStateForAnalysisTask(this._task));
+        }
+
+        this._analysisResultsViewer.setCurrentTestGroup(this._currentTestGroup);
+        this._analysisResultsViewer.render();
+
+        var element = ComponentBase.createElement;
+        var link = ComponentBase.createLink;
+        if (this._testGroups != this._renderedTestGroups) {
+            this._renderedTestGroups = this._testGroups;
+            var self = this;
+            this.renderReplace(this.content().querySelector('.test-group-list'),
+                this._testGroups.map(function (group) {
+                    return element('li', {class: 'test-group-list-' + group.id()}, link(group.label(), function () {
+                        self._showTestGroup(group);
+                    }));
+                }));
+            this._renderedCurrentTestGroup = null;
+        }
+        if (this._renderedCurrentTestGroup != this._currentTestGroup) {
+            if (this._renderedCurrentTestGroup) {
+                var element = this.content().querySelector('.test-group-list-' + this._renderedCurrentTestGroup.id());
+                if (element)
+                    element.classList.remove('selected');
+            }
+            if (this._currentTestGroup) {
+                var element = this.content().querySelector('.test-group-list-' + this._currentTestGroup.id());
+                if (element)
+                    element.classList.add('selected');
+            }
+            this._renderedCurrentTestGroup = this._currentTestGroup;
         }
 
+        this._testGroupResultsTable.render();
+
         Instrumentation.endMeasuringTime('AnalysisTaskPage', 'render');
     }
 
+    _showTestGroup(testGroup)
+    {
+        this._currentTestGroup = testGroup;        
+        this._testGroupResultsTable.setTestGroup(this._currentTestGroup);
+        this.render();
+    }
+
     static htmlTemplate()
     {
         return `
-            <h2 class="analysis-task-name"></h2>
-            <p class="error-message"></p>
-            <div class="overview-chart"></div>
+        <div class="analysis-tasl-page-container">
+            <div class="analysis-tasl-page">
+                <h2 class="analysis-task-name"></h2>
+                <h3 class="platform-metric-names"><a href=""></a></h3>
+                <p class="error-message"></p>
+                <div class="overview-chart"></div>
+                <section class="analysis-results-view">
+                    <analysis-results-viewer></analysis-results-viewer>
+                </section>
+                <section class="test-group-view">
+                    <ul class="test-group-list"></ul>
+                    <div class="test-group-details"><test-group-results-table></test-group-results-table></div>
+                </section>
+            </div>
+        </div>
 `;
     }
 
     static cssTemplate()
     {
         return `
+            .analysis-tasl-page-container {
+                text-align: center;
+            }
+            .analysis-tasl-page {
+                display: inline-block;
+                text-align: left;
+            }
+
             .analysis-task-name {
                 font-size: 1.2rem;
                 font-weight: inherit;
@@ -64,12 +219,84 @@ class AnalysisTaskPage extends PageWithHeading {
                 padding: 0;
             }
 
+            .platform-metric-names {
+                font-size: 1rem;
+                font-weight: inherit;
+                color: #c93;
+                margin: 0 1rem;
+                padding: 0;
+            }
+
+            .platform-metric-names a {
+                text-decoration: none;
+                color: inherit;
+            }
+
+            .platform-metric-names:empty {
+                margin: 0;
+            }
+
             .error-message:not(:empty) {
                 margin: 1rem;
                 padding: 0;
             }
 
-            .overview-chart {
+            .analysis-results-view {
+                margin: 1rem;
+            }
+
+            .test-group-view {
+                display: flex;
+                flex-direction: row;
+                align-items: stretch;
+                align-content: stretch;
+                margin: 0 1rem;
+            }
+
+            .test-group-details {
+                display: flex;
+                flex-grow: 1;
+                margin-bottom: 1rem;
+            }
+
+            .test-configuration h3 {
+                font-size: 1rem;
+                font-weight: inherit;
+                color: inherit;
+                margin: 0 1rem;
+                padding: 0;
+            }
+
+            .test-group-list:not(:empty) {
+                margin: 0;
+                padding: 0.2rem 0;
+                list-style: none;
+                display: inline-block;
+                border-right: solid 1px #ccc;
+            }
+
+            .test-group-list li {
+                display: block;
+            }
+
+            .test-group-list a {
+                display: block;
+                color: inherit;
+                text-decoration: none;
+                font-size: 0.9rem;
+                margin: 0;
+                padding: 0.2rem;
+            }
+
+            .test-group-list li.selected a {
+                background: rgba(204, 153, 51, 0.1);
+            }
+
+            .test-group-list li:not(.selected) a:hover {
+                background: #eee;
+            }
+
+            .x-overview-chart {
                 width: auto;
                 height: 10rem;
                 margin: 1rem;
index 958d550..85f3d70 100644 (file)
@@ -19,6 +19,17 @@ class ChartsPage extends PageWithCharts {
         return state;
     }
 
+    static createStateForAnalysisTask(task)
+    {
+        var diff = (task.endTime() - task.startTime()) * 0.1;
+        var state = {
+            paneList: [[task.platform().id(), task.metric().id()]],
+            since: Math.round(task.startTime() - (Date.now() - task.startTime()) * 0.1),
+            zoom: [task.startTime() - diff, task.endTime() + diff],
+        };
+        return state;
+    }
+
     open(state)
     {
         this.toolbar().setNumberOfDaysCallback(this.setNumberOfDaysFromToolbar.bind(this));