Merge database-common.js and utility.js into run-tests.js.
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 8 Feb 2014 03:11:53 +0000 (03:11 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 8 Feb 2014 03:11:53 +0000 (03:11 +0000)
Reviewed by Matthew Hanson.

Now that run-tests is the only node.js script, merged database-common.js and utility.js into it.
Also moved init-database.sql out of the database directory and removed the directory entirely.

* database: Removed.
* database/database-common.js: Removed.
* database/utility.js: Removed.
* init-database.sql: Moved from database/init-database.sql.
* run-tests.js:
(connect): Moved from database-common.js.
(pathToDatabseSQL): Extracted from pathToLocalScript.
(pathToTests): Moved from database-common.js.
(config): Ditto.
(TaskQueue): Ditto.
(SerializedTaskQueue): Ditto.
(main):
(initializeDatabase):
(TestEnvironment.it):
(TestEnvironment.queryAndFetchAll):
(sendHttpRequest):

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

53 files changed:
Websites/perf.webkit.org/ChangeLog [new file with mode: 0644]
Websites/perf.webkit.org/Install.md [new file with mode: 0644]
Websites/perf.webkit.org/ReadMe.md [new file with mode: 0644]
Websites/perf.webkit.org/config.json [new file with mode: 0644]
Websites/perf.webkit.org/init-database.sql [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/README [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/admin.css [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/aggregators.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/bug-trackers.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/builders.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/index.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/jobs.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/platforms.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/regenerate-manifest.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/reports.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/repositories.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/reprocess-report.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/test-configurations.php [new file with mode: 0644]
Websites/perf.webkit.org/public/admin/tests.php [new file with mode: 0644]
Websites/perf.webkit.org/public/api/report.php [new file with mode: 0644]
Websites/perf.webkit.org/public/api/runs.php [new file with mode: 0644]
Websites/perf.webkit.org/public/common.css [new file with mode: 0644]
Websites/perf.webkit.org/public/include/admin-footer.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/admin-header.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/db.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/json-header.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/manifest.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/report-processor.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/test-name-resolver.php [new file with mode: 0644]
Websites/perf.webkit.org/public/index.html [new file with mode: 0644]
Websites/perf.webkit.org/public/js/helper-classes.js [new file with mode: 0755]
Websites/perf.webkit.org/public/js/jquery.colorhelpers.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.categories.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.crosshair.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.errorbars.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.fillbetween.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.navigate.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.plugins.js [new file with mode: 0755]
Websites/perf.webkit.org/public/js/jquery.flot.resize.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.selection.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.stack.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.symbol.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.threshold.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.flot.time.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/jquery.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/shared.js [new file with mode: 0644]
Websites/perf.webkit.org/public/js/statistics.js [new file with mode: 0644]
Websites/perf.webkit.org/run-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/tests/admin-platforms.js [new file with mode: 0644]
Websites/perf.webkit.org/tests/admin-regenerate-manifest.js [new file with mode: 0644]
Websites/perf.webkit.org/tests/admin-reprocess-report.js [new file with mode: 0644]
Websites/perf.webkit.org/tests/api-report.js [new file with mode: 0644]

diff --git a/Websites/perf.webkit.org/ChangeLog b/Websites/perf.webkit.org/ChangeLog
new file mode 100644 (file)
index 0000000..005d2e7
--- /dev/null
@@ -0,0 +1,2058 @@
+2014-01-31  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Merge database-common.js and utility.js into run-tests.js.
+
+        Reviewed by Matthew Hanson.
+
+        Now that run-tests is the only node.js script, merged database-common.js and utility.js into it.
+        Also moved init-database.sql out of the database directory and removed the directory entirely.
+
+        * database: Removed.
+        * database/database-common.js: Removed.
+        * database/utility.js: Removed.
+        * init-database.sql: Moved from database/init-database.sql.
+        * run-tests.js:
+        (connect): Moved from database-common.js.
+        (pathToDatabseSQL): Extracted from pathToLocalScript.
+        (pathToTests): Moved from database-common.js.
+        (config): Ditto.
+        (TaskQueue): Ditto.
+        (SerializedTaskQueue): Ditto.
+        (main):
+        (initializeDatabase):
+        (TestEnvironment.it):
+        (TestEnvironment.queryAndFetchAll):
+        (sendHttpRequest):
+
+2014-01-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Remove the dependency on node.js from the production code.
+
+        Reviewed by Ricky Mondello.
+
+        Work towards <rdar://problem/15955053> Upstream SafariPerfMonitor.
+
+        Removed node.js dependency from TestRunsGenerator. It was really a design mistake to invoke node.js from php.
+        It added so much complexity with only theoretical extensibility of adding aggregators.  It turns out that
+        many aggregators we'd like to add are a lot more complicated than ones that could be written under the current
+        infrastructure, and we need to make the other aspects (e.g. the level of aggregations) a lot more extensible.
+        Removing and simplifying TestRunsGenerator allows us to implement such extensions in the future.
+
+        Also removed the js files that are no longer used.
+
+        * config.json: Moved from database/config.json.
+        * database/aggregate.js: Removed. No longer used.
+        * database/database-common.js: Removed unused functions, and updated the path to config.json.
+        * database/process-jobs.js: Removed. No longer used.
+        * database/sample-data.sql: Removed. We have a much better corpus of data now.
+        * database/schema.graffle: Removed. It's completely obsolete.
+        * public/include/db.php: Updated the path to config.json.
+        * public/include/evaluator.js: Removed.
+
+        * public/include/report-processor.php:
+        (TestRunsGenerator::aggregate): Directly aggregate values via newly added aggregate_values method instead of
+        storing values into $expressions and calling evaluate_expressions_by_node.
+        (TestRunsGenerator::aggregate_values): Added.
+        (TestRunsGenerator::compute_caches): Directly compute the caches.
+
+2014-01-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Don't fail the platform merges even if there are no test configurations to be moved to the new platform.
+
+        * public/admin/platforms.php:
+        * public/include/db.php:
+
+2014-01-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Zoomed y-axis view is ununsable when the last result is an outlier.
+
+        Reviewed by Stephanie Lewis.
+
+        Show two standard deviations from the exponential moving average with alpha = 0.3 instead of the mean of
+        the last result so that the graph looks sane if the last result was an outlier. However, always show
+        the last result's mean even if it was an outlier.
+
+        * public/index.html:
+        * public/js/helper-classes.js:
+        (unscaledMeansForAllResults): Extracted from min/max/sampleStandardDeviation.
+        Also added the ability to cache the unscaled means to avoid recomputation.
+        (PerfTestRuns.min): Refactored to use unscaledMeansForAllResults.
+        (PerfTestRuns.max): Ditto.
+        (PerfTestRuns.sampleStandardDeviation): Ditto.
+        (PerfTestRuns.exponentialMovingArithmeticMean): Added.
+
+2014-01-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Minor fixes.
+
+        * public/admin/tests.php:
+        * public/js/helper-classes.js:
+
+2014-01-29  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Use two standard deviations instead as I mentioned in the mailing list.
+
+        * public/index.html:
+
+2014-01-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        The performance dashboard erroneously shows upward arrow for combined metrics.
+
+        A single outlier can ruin the zoomed y-axis view.
+
+        Rubber-stamped by Antti Koivisto.
+
+        * public/index.html:
+        (computeYAxisBoundsToFitLines): Added adjustedMax and adjustedMin, which are pegged at 4 standard deviations
+        from the latest results' mean.
+        (Chart): Renamed shouldStartYAxisAtZero to shouldShowEntireYAxis.
+        (Chart.attachMainPlot): Use the adjusted max and min when we're not showing the entire y-axis.
+        (Chart.toggleYAxis):
+        * public/js/helper-classes.js:
+        (PerfTestRuns.sampleStandardDeviation): Added.
+        (PerfTestRuns.smallerIsBetter): 'Combined' is a smaller is better metric.
+
+2014-01-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Don't include the confidence interval when computing the y-axis.
+
+        Rubber-stamped by Simon Fraser.
+
+        * public/js/helper-classes.js:
+        (PerfTestRuns.min):
+        (PerfTestRuns.max):
+
+2014-01-25  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Tiny CSS tweak for tooltips.
+
+        * public/index.html:
+
+2014-01-25  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Remove the erroneously repeated code.
+
+        * public/admin/test-configurations.php:
+
+2014-01-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15704893> perf dashboard should show baseline numbers
+
+        Reviewed by Stephanie Lewis.
+
+        * public/admin/bug-trackers.php:
+        (associated_repositories): Return an array of HTMLs instead of echo'ing as expected by AdministrativePage.
+        Also fixed a typo.
+
+        * public/admin/platforms.php:
+        (merge_list): Ditto.
+
+        * public/admin/test-configurations.php: Added.
+        (add_run): Adds a "synthetic" test run and a corresponding build. It doesn't create run_iterations and
+        build_revisions as they're not meaningful for baseline / target numbers.
+        (delete_run): Deletes a synthetic test run and its build. It verifies that the specified build has exactly
+        one test run so that we don't accidentally delete a reported test run.
+        (generate_rows_for_configurations): Generates rows of configuration IDs and types.
+        (generate_rows_for_test_runs): Ditto for test runs. It also emits the form to add new "synthetic" test runs
+        and delete existing ones.
+
+        * public/admin/tests.php: We wrongfully assumed there is exactly one test configuration for each metric
+        on each platform; there could be configurations of distinct types such as "current" and "baseline".
+        Thus, update all test configurations for a given metric when updating config_is_in_dashboard.
+
+        * public/api/runs.php: Remove the NotImplemented when we have multiple test configurations.
+        (fetch_runs_for_config): "Synthetic" test runs created on test-configurations page are missing revision
+        data so we need to left-outer-join (instead of inner-join) build_revisions. To avoid making the query
+        unreadable, don't join revision_repository here. Instead, fetch the list of repositories upfront and
+        resolve names in parse_revisions_array. This actually reduces the query time by ~10%.
+
+        (parse_revisions_array): Skip an empty array created for "synthetic" test runs.
+
+        * public/include/admin-header.php:
+        (AdministrativePage::render_table): Now custom columns support sub columns. e.g. a configuration column may
+        have id and type sub columns, and each custom column could generate multiple rows.
+
+        Any table with sub columns now generates two rows for thead. We generate td's in in the first row without
+        sub columns with rowspan of 2, and generate ones with sub columns with colspan set to the sub column count.
+        We then proceed to generate the second header row with sub column names.
+
+        When generating the actual content, we first generate all custom columns as they may have multiple rows in
+        which case regular columns need rowspan set to the maximum number of rows.
+
+        Once we've generated the first row, we proceed to generate subsequent rows for those custom columns that
+        have multiple rows.
+
+        (AdministrativePage::render_custom_cells): Added. This function is responsible for generating table cells
+        for a given row in a given custom column. It generates an empty td when the custom column doesn't have
+        enough rows. It also generates empty an td when it doesn't have enough columns in some rows except when
+        the entire row consists of exactly one cell for a custom column with sub columns, in which case the cell is
+        expanded to occupy all sub columns.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::platforms): Don't add the metric more than once.
+
+        * public/include/test-name-resolver.php:
+        (TestNameResolver::__construct): We had wrongfully assumed that we have exactly one test configuration on
+        each platform for each metric like tests.php. Fixed that. Also fetch the list of aggregators to compute the
+        full metric name later.
+        (TestNameResolver::map_metrics_to_tests): Populate $this->id_to_metric.
+        (TestNameResolver::test_id_for_full_name): Simplified the code using array_get.
+        (TestNameResolver::full_name_for_test): Added.
+        (TestNameResolver::full_name_for_metric): Added.
+        (TestNameResolver::configurations_for_metric_and_platform): Renamed as it returns multiple configurations.
+
+        * public/js/helper-classes.js:
+        (TestBuild): Use the build time as the maximum time when revision information is missing for "synthetic"
+        test runs created to set baseline and target points.
+
+2014-01-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix after r57928. Removed a superfluous close parenthesis.
+
+        * public/api/runs.php:
+
+2014-01-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed build & typo fixes.
+
+        * public/admin/platforms.php:
+        * tests/admin-platforms.js:
+
+2014-01-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15704893> perf dashboard should show baseline numbers
+
+        Rubber-stamped by Antti Koivisto.
+
+        Organize some code into functions in runs.php.
+
+        Also added back $paths that was erroneously removed in r57925 from json-header.php.
+
+        * public/api/runs.php:
+        (fetch_runs_for_config): Extracted.
+        (format_run): Ditto.
+
+2014-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Merge the upstream json-shared.php as of https://trac.webkit.org/r162693.
+
+        * database/config.json:
+        * public/admin/reprocess-report.php:
+        * public/api/report.php:
+        * public/api/runs.php:
+        * public/include/json-header.php:
+
+2014-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit yet another forgotten change.
+
+        Something went horribly wrong with my merge :(
+
+        * database/init-database.sql:
+
+2014-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit one more forgotten change. Sorry for making a mess here.
+
+2014-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit the forgotten files.
+
+        * public/admin/platforms.php: Added.
+        * tests/admin-platforms.js: Added.
+
+2014-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15889905> SafariPerfMonitor: there should be a way to merge and hide platforms
+
+        Reviewed by Stephanie Lewis.
+
+        Added /admin/platforms/ page to hide and merge platforms.
+
+        Merging two platforms is tricky because we need to migrate test runs as well as some test configurations.
+        Recall that each test (e.g. Dromaeo) can have many "test metrics" (e.g. MaxAllocations, EndAllocations),
+        and they have a distinct "test configuration" for each platform (e.g. MaxAllocation on Mountain Lion), and
+        each test configuration a distinct "test run" for each build.
+
+        In order to merge platform A into platform B, we must migrate all test runs that belong to platform A via
+        their test configurations into platform B.
+
+        Suppose we're migrating a test run R for test configuration T_A in platform A for metric M. Since M exists
+        independent of platforms, R should continue to relate to M through some test configuration. Unfortunately,
+        we can't simply move T_A into platform B since we may already have a test configuration T_B for metric M
+        in platform B, in which case R should relate to T_B instead.
+
+        Thus, we first migrate all test runs for which we already have corresponding test configurations in the
+        new platform. We then migrate the test configurations of the remaining test runs.
+
+        * database/init-database.sql: Added platform_hidden.
+
+        * public/admin/platforms.php: Added.
+        (merge_platforms): Added. Implements the algorithm described above.
+        (merge_list): Added.
+
+        * public/admin/tests.php: Disable the checkbox to show a test configuration on the dashboard if its platform
+        is hidden since it doesn't do anything.
+
+        * public/include/admin-header.php: Added the hyperlink to /admin/platforms.
+        (update_field): Don't bail out if the newly added "update-column" is set to the field name even if $_POST is
+        missing it since unchecked checkbox doesn't set the value in $_POST.
+        (AdministrativePage::render_form_control_for_column): Added the support for boolean edit mode. Also used
+        switch statement instead of repeated if's.
+        (AdministrativePage::render_table): Emit "update-column" for update_field.
+
+        * public/include/db.php: Disable warnings when we're not in the debug mode.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::platforms): Skip platforms that have been hidden.
+
+        * run-tests.js:
+        (TestEnvironment.postJSON):
+        (TestEnvironment.httpGet):
+        (TestEnvironment.httpPost): Added.
+        (sendHttpRequest): Set the content type if specified.
+
+        * tests/admin-platforms.js: Added tests.
+
+2014-01-22  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Extract the code to compute full test names from tests.php.
+
+        Reviewed by Stephanie Lewis.
+
+        Extracted TestNameResolver out of tests.php. This reduces the number of global variables in tests.php
+        and paves our way to re-use the code in other pages.
+
+        * public/admin/tests.php:
+
+        * public/include/db.php:
+        (array_set_default): Renamed from array_item_set_default and moved from tests.php as it's used in both
+        tests.php and test-name-resolver.php.
+
+        * public/include/test-name-resolver.php: Added.
+        (TestNameResolver::__construct):
+        (TestNameResolver::compute_full_name): Moved from tests.php.
+        (TestNameResolver::map_metrics_to_tests): Ditto.
+        (TestNameResolver::sort_tests_by_full_name): Ditto.
+        (TestNameResolver::tests): Added.
+        (TestNameResolver::test_id_for_full_name): Ditto.
+        (TestNameResolver::metrics_for_test_id): Ditto.
+        (TestNameResolver::child_metrics_for_test_id): Ditto.
+        (TestNameResolver::configuration_for_metric_and_platform): Ditto.
+
+2014-01-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15867325> Perf dashboard is erroneously associating reported results with old revisions
+
+        Reviewed by Stephanie Lewis.
+
+        Add the ability to reprocess reports so that I can re-associate wrongfully associated reports.
+
+        Added public/admin/reprocess-report.php. It doesn't have any nice UI to find reports and it returns JSON
+        but that's sufficient to correct the wrongfully processed reports for now.
+
+        * public/admin/reprocess-report.php: Added. Takes a report id in $_GET or $_POST and process the report.
+        We should eventually add a nice UI to find and reprocess reports.
+
+        * public/api/report.php: ReportProcessor and TestRunsGenerator have been removed.
+
+        * public/include/db.php: Added the forgotten call to prefixed_column_names.
+
+        * public/include/report-processor.php: Copied from public/api/report.php.
+        (ReportProcessor::__construct): Fetch the list of aggregators here for simplicity.
+        (ReportProcessor::process): Optionally takes $existing_report_id. When this value is specified, we don't
+        create a new report or authenticate the builder password (the password is never stored in the report).
+        Also use select_first_row instead of query_and_fetch_all to find the builder for simplicity.
+        (ReportProcessor::construct_build_data): Extracted from store_report_and_get_build_data.
+        (ReportProcessor::store_report): Ditto.
+
+        * tests/admin-reprocess-report.js: Added.
+
+2014-01-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15867325> Perf dashboard is erroneously associating reported results with old revisions
+
+        Reviewed by Ricky Mondello.
+
+        The bug was caused by a build fix r57645. It attempted to treat multiple reports from the same builder
+        for the same build number as a single build by ignoring build time. This was necessary to associate
+        multiple reports by a single build - e.g. for different performance test suites - because the scripts
+        we use to submit results computed its own "build time" when they're called.
+
+        An unintended consequence of this change was revealed when we moved a buildbot master to the new machine
+        last week; new reports were wrongfully associated with old build numbers.
+
+        Fixed the bug by not allowing reports made more than 1 day after the initial build time to be assigned
+        to the same build. Instead, we create a new build object for those reports. Since the longest set of
+        tests we have only take a couple of hours to run, 24 hours should be more than enough.
+
+        * database/init-database.sql: We can no longer constrain that each build number is unique to a builder
+        or that build number and build time pair is unique. Instead, constrain the uniqueness of the tuple
+        (builder, build number, build time).
+
+        * public/api/report.php:
+        (ReportProcessor::resolve_build_id): Look for any builds made within the past one day. Create a new build
+        when no such build exists. This prevents a report from being associated with a very old build of the same
+        build number.
+
+        Also check that revision numbers or hashes match when we're adding revision info. This will let us catch
+        a similar bug in the future sooner.
+
+        * tests/api-report.js: Added three test cases.
+
+2014-01-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Merged the upstream changes to db.php
+        See http://trac.webkit.org/browser/trunk/Websites/test-results/public/include/db.php
+
+        * public/include/db.php:
+
+2014-01-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Update other scripts and tests per previous patch.
+
+        * public/include/manifest.php:
+        * tests/admin-regenerate-manifest.js:
+
+2014-01-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Remove metrics_unit.
+
+        Reviewed by Ricky Mondello.
+
+        This column is no longer used by the front-end code since r48360.
+
+        * database/init-database.sql:
+        * public/admin/tests.php:
+
+2014-01-16  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed build fix.
+
+        * public/api/report.php:
+
+2014-01-15  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/15832456> Automate DoYouEvenBench (124497)
+
+        Reviewed by Ricky Mondello.
+
+        Support a new alternative format for aggregated results where we have raw values as well as
+        the list aggregators so that instead of
+        "metrics": {"Time": ["Arithmetic"]}
+        we can have
+        "metrics": {"Time": { "aggregators" : ["Arithmetic"], "current": [300, 310, 320, 330] }}
+
+        This allows single JSON generated by run-perf-tests in WebKit to be shared between the perf
+        dashboard and the generated results page, which doesn't know how to aggregate values.
+
+        We need to keep the support for the old format because all other existing performance tests
+        all rely on the old format. Even if we updated the tests, we need the dashboard to support
+        the old format during the transition.
+
+        * public/api/report.php:
+        (ReportProcessor::recursively_ensure_tests): Support the new format in addition to the old one.
+        (ReportProcessor::aggregator_list_if_exists): Replaced is_list_of_aggregators.
+
+        * tests/api-report.js: Updated one of aggregator test cases to test the new format.
+
+2013-05-31  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed; Tweak the CSS so that chart panes align vertically.
+
+        * public/index.html:
+
+2013-05-31  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor should support Combined metric.
+
+        * public/js/helper-classes.js:
+        (PerfTestRuns): Added 'Combined' metric. In general, it could be used for smaller-is-better
+        value as well but assume it to be greater-is-better for now.
+
+2013-05-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit the forgotten init-database change to add iteration_relative_time.
+
+        * database/init-database.sql:
+
+2013-05-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13993069> SafariPerfMonitor: Support accepting (relative time, value) pairs
+
+        Reviewed by Ricky Mondello.
+
+        Add the support for each value to have a relative time. This is necessary for frame rate history
+        since a frame rate needs to be associated with a time it was sampled.
+
+        * database/init-database.sql: Added iteration_relative_time to run_iterations.
+
+        * public/api/report.php:
+        (TestRunsGenerator::test_value_list_to_values_by_iterations): Reject any non-numeral values here.
+        This code is used to aggregate values but it doesn't make sense to aggregate iteration values
+        with relative time since taking the average of two frame rates for two subtests taken at two
+        different times doesn't make any sense.
+        (TestRunsGenerator::compute_caches): When we encounter an array value while computing sum, mean,
+        etc..., use the second element since we assume values are of the form (relative time, frame rate).
+        Also exit early with an error if the number of elements in the array is not a pair.
+        (TestRunsGenerator::commit): Store the relative time and the frame rate as needed.
+
+        * tests/api-report.js: Added a test case. Also modified existing test cases to account for
+        iteration_relative_time.
+
+2013-05-27  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13654488> SafariPerfMonitor: Support accepting single-value results
+
+        Reviewed by Ricky Mondello.
+
+        Support that. It's one line change.
+
+        * public/api/report.php:
+        (ReportProcessor.recursively_ensure_tests): When there is exactly one value, wrap it inside an array
+        to match the convention assumed elsewhere.
+        * tests/api-report.js: Added a test case.
+
+2013-05-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor shows popups for points outside of the visible region.
+
+        Rubber-stamped by Simon Fraser.
+
+        * public/index.html:
+        (Chart.closestItemForPageXRespectingPlotOffset): renamed from closestItemForPageX.
+        (Chart.attach): Always use closestItemForPageXRespectingPlotOffset to work around the fact flot
+        may return an item underneath y-axis labels.
+
+2013-05-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Tweak the CSS a little to avoid the test name overlapping with the summary table.
+
+        * public/index.html:
+
+2013-05-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Fix the typo. The anchor element should wrap the svg element, not the other way around.
+
+        * public/index.html:
+
+2013-05-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13992266> Should be a toggle to show entire Y-axis range
+        <rdar://problem/13992271> Should scale Y axis to include error ranges
+
+        Reviewed by Ricky Mondello.
+
+        Add the feature. Also made adjust y-axis respect confidence interval delta so that the gray shade behind
+        the main graph doesn't go outside the graph even when the y-axis is adjusted.
+
+        * database/config.json:
+        * public/index.html:
+        (Chart): Add a SVG arrow to toggle y-axis mode, and bind click on the arrow to toggleYAxis().
+        (Chart.attachMainPlot): Respect shouldStartYAxisAtZero.
+        (Chart.toggleYAxis): Toggle the y-axis mode of this chart by toggling shouldStartYAxisAtZero and calling
+        attachMainPlot.
+        * public/js/helper-classes.js:
+        (PerfTestResult.confidenceIntervalDelta):
+        (PerfTestResult.unscaledConfidenceIntervalDelta): Extracted.
+        (PerfTestRuns.min): Take confidence interval delta into account.
+        (PerfTestRuns.max): Ditto.
+        (PerfTestRuns.hasConfidenceInterval): Not sure why this function was checking the typeof. Just use isNaN.
+
+2013-04-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        A build fix of the previous. Don't look for a test with NULL parent because NULL != NULL in our beloved SQL.
+
+        * public/api/report.php:
+        (ReportProcessor::recursively_ensure_tests):
+        * tests/api-report.js: Added a test.
+
+2013-04-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed build fixes.
+
+        * public/api/report.php:
+        (ReportProcessor::process): Explicitly exit with error when builder name or build time is missing.
+        Also, tolerate reports without any revision information.
+
+        (ReportProcessor::recursively_ensure_tests): When looking for a test, don't forget to compare its
+        parent test.
+
+        * tests/api-report.js: Added few test cases.
+
+2013-04-26  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit another change that was supposed to be committed in r50331.
+
+        * run-tests.js:
+        (TestEnvironment.this.postJSON):
+        (TestEnvironment.this.httpGet):
+        (sendHttpRequest):
+
+2013-04-09  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Commit the remaining files.
+
+        * public/admin/regenerate-manifest.php:
+        * public/include/admin-header.php:
+        * public/include/json-header.php:
+        * public/include/manifest.php:
+        * run-tests.js:
+        (TestEnvironment.this.postJSON):
+        (TestEnvironment.this.httpGet):
+        (sendHttpRequest):
+
+2013-03-15  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Add some tests for admin/regenerate-manifest.
+
+        Reviewed by Ricky Mondello.
+
+        Added some tests for admin/regenerate-manifest.
+
+        * public/admin/regenerate-manifest.php: Use require_once instead of require.
+        * public/include/admin-header.php: Ditto.
+        * public/include/json-header.php: Ditto.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::builders): Removed a reference to a non-existent variable.
+        When there are no builders, simply return an empty array.
+
+        * run-tests.js:
+        (TestEnvironment.postJSON):
+        (TestEnvironment.httpGet): Added.
+        (sendHttpRequest): Renamed from postHttpRequest as it now takes method as an argument.
+
+        * tests/admin-regenerate-manifest.js: Added with a bunch of test cases.
+
+2013-03-14  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Added more tests for api/report to ensure it creates tests, metrics, test_runs,
+        and run_iterations. Also fixed a typo in report.php found by new tests.
+
+        * public/api/report.php:
+        (main): Fix a bug in the regular expression to wrap numbers with double quotations.
+        * tests/api-report.js: Added more test cases.
+
+2013-03-12  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13399038> SafariPerfMonitor: Need integration tests
+
+        Reviewed by Ricky Mondello.
+
+        Add a test runner script and some simple test cases.
+
+        * database/config.json: Added the configuration for "testServer".
+        * database/database-common.js:
+        (pathToTests): Added.
+        * run-tests.js: Added.
+        (main):
+
+        (confirmUserWantsDatabaseToBeInitializedIfNeeded): Checks whether there are any non-empty tables,
+        and if there are, asks the user if it’s okay to delete all of the data contained therein.
+        (confirmUserWantsDatabaseToBeInitializedIfNeeded.findNonEmptyTable): Find a table with non-zero
+        number of rows.
+        (confirmUserWantsDatabaseToBeInitializedIfNeeded.fetchTableNames): Fetch the list of all tables
+        in the current database using PostgreSQL's information_schema.
+        (askYesOrNoQuestion):
+
+        (initializeDatabase): Executes init-database.sql. It drops all tables and creates them again.
+
+        (TestEnvironment): The global object exposed in tests. Provides various utility functions.
+        (TestEnvironment.assert): Exposes assert to tests.
+        (TestEnvironment.console): Exposes console to tests.
+        (TestEnvironment.describe): Adds a description.
+        (TestEnvironment.it): Adds a test case.
+        (TestEnvironment.postJSON):
+        (TestEnvironment.queryAndFetchAll):
+        (TestEnvironment.sha256):
+        (TestEnvironment.notifyDone): Ends the current test case.
+
+        (postHttpRequest):
+
+        (TestContext): An object created for each test case. Conceptually, this object is always on
+        "stack" when a test case is running. TestEnvironment and an uncaughtException handler accesses
+        this object via currentTestContext.
+        (TestContext.description):
+        (TestContext.done):
+        (TestContext.logError):
+
+        * tests: Added.
+        * tests/api-report.js: Added some basic tests for /api/report.php.
+
+2013-03-08  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed administrative page fix. Make it possible to remove all configuration from dashboard.
+
+        The problem was that we were detecting whether we're updating dashboard or not by checking
+        the existence of metric_configurations in $_POST but this key doesn't exist when we're removing
+        all configurations. Use separate 'dashboard' action to execute the code even when
+        metric_configurations is empty.
+
+        * public/admin/tests.php:
+
+2013-03-08  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Extract a class to aggregate and store values from ReportProcessor.
+
+        Reviewed by Ricky Mondello.
+
+        This patch extracts TestRunsGenerator, which aggregates and compute caches of values,
+        from ReportProcessor as a preparation to replace deprecated aggregate.js.
+
+        * public/api/report.php:
+        (ReportProcessor::exit_with_error): Moved.
+        (ReportProcessor::process): Use the extracted TestRunsGenerator.
+        (TestRunsGenerator): Added.
+        (TestRunsGenerator::exit_with_error): Copied from ReportProcessor.
+        (TestRunsGenerator::add_aggregated_metric): Moved.
+        (TestRunsGenerator::add_values_for_aggregation): Moved. Made public.
+        (TestRunsGenerator::aggregate): Moved. Made public.
+        (TestRunsGenerator::aggregate_current_test_level): Moved.
+        (TestRunsGenerator::test_value_list_to_values_by_iterations): Moved.
+        (TestRunsGenerator::evaluate_expressions_by_node): Moved.
+        (TestRunsGenerator::compute_caches): Moved. Made public.
+        (TestRunsGenerator::add_values_to_commit): Moved. Made public.
+        (TestRunsGenerator::commit): Moved. Made public. Also takes build_id and platform_id.
+        (TestRunsGenerator::rollback_with_error): Moved.
+
+2013-03-08  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Administrative pages should update manifest JSON as needed.
+
+        Reviewed by Remy Demarest.
+
+        Regenerate the manifest file when updating fields or adding new items that are included in
+        the manifest JSON.
+
+        * public/admin/bug-trackers.php:
+        * public/admin/builders.php:
+        * public/admin/regenerate-manifest.php:
+        * public/admin/repositories.php:
+        * public/admin/tests.php:
+        * public/include/admin-header.php:
+        (regenerate_manifest): Extracted from regenerate-manifest.php.
+
+2013-03-08  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed build fix for memory test results.
+
+        Make aggregation work in the nested cases. We start from the "leaf" tests and move our ways up,
+        aggregating at each level.
+
+        * public/api/report.php:
+        (ReportProcessor::recursively_ensure_tests):
+        (ReportProcessor::add_aggregated_metric): Renamed from ensure_aggregated_metric.
+        (ReportProcessor::add_values_for_aggregation):
+        (ReportProcessor::aggregate):
+        (ReportProcessor::aggregate_current_test_level): Extracted from aggregate.
+
+2013-03-02  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fixes. iteration_count_cache should be the total number of values in all iteration group,
+        not the number of iteration groups. Also, don't set group number when the entire run belongs
+        a single iteration group.
+
+        * public/api/report.php:
+        (ReportProcessor::commit):
+
+2013-03-01  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Introduce iteration groups
+
+        Reviewed by Remy Demarest.
+
+        In WebKit land, we're going to use multiple instances of DumpRenderTree or WebKitTestRunner to amortize
+        the runtime environment variances to get more stable results. And it's desirable to keep track of
+        the instance of DumpRenderTree or WebKitTestRunner used to generate each iteration value.
+
+        This patch introduces "iteration groups" to keep track of this extra information.
+
+        Instead of receiving a flat array of iteration values, we can now receive a two dimensional array where
+        the outer array denotes iteration groups and each inner array contains iteration values for each group.
+
+
+        * database/init-database.sql: Add iteration_group column.
+        * public/api/report.php:
+        (ReportProcessor::recursively_ensure_tests): Always use the two dimensional array internally.
+
+        (ReportProcessor::aggregate): test_value_list_to_values_by_iterations now returns an associative array
+        contains the list of values indexed by the iteration order and group sizes. Store the group size so
+        that we can restore the iteration groups before passing it to node.js and restore them later.
+
+        (ReportProcessor::test_value_list_to_values_by_iterations): Flatten iteration groups into an array
+        of values and construct group_size array to restore the groups later in ReportProcessor::aggregate.
+
+        Also check that each iteration group in each subtest are consistent with one another. To see why we need
+        to do this, suppose we're aggregating two tests T1 and T2 with the following values. Then it's important
+        that each iteration group in T1 and T2 have the same size:
+        T1 = [[1, 2], [3, 4, 5]]
+        T2 = [[6, 7], [8, 9, 10]]
+
+        so that the aggregated result (the sum in this case) can have the same groups as in:
+        T  = [[7, 9], [11, 13, 15]]
+
+        If some iteration groups in T1 and T2 had a different size as in:
+        T1 = [[1, 2, 3], [4, 5]]
+        T2 = [[6, 7], [8, 9, 10]]
+
+        Then iteration groups of the aggregated T is ambiguous.
+
+        (ReportProcessor::compute_caches): Flatten iteration groups to compute caches (e.g. mean, stdev, etc...)
+        (ReportProcessor::commit): Store iteration_group values.
+
+2013-03-01  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Delete the migration tool for webkit-perf.appspot.com now that we have successfully
+        migrated to perf.webkit.org.
+
+        * database/perf-webkit-migrator.js: Removed.
+
+2013-03-01  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Don't forget to add metrics of the top level tests e.g. Dromaeo:Time:Arithmetic.
+
+        * public/index.html:
+        (.showCharts):
+
+2013-03-01  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Make it possible to add charts for all subtests or all platforms.
+
+        Reviewed by Ricky Mondello.
+
+        It is often desirable to see charts of a given test for all platforms, or to be able to see
+        charts of all subtests on a given platform when trying to triage perf. regressions.
+
+        Support this use case by adding the ability to do so on the charts page.
+
+        Also, we used to disable items on the test list based on the platform chosen. This turned out
+        to be a bad UI because in many situations you want to be able to compare results of the same test
+        on multiple platforms.
+
+        In this new UI, we have three select elements, each of which selects the following:
+        1. Top-level test - Test suite such as Dromaeo
+        2. Metric - Pages and subtests under the suite such as www.webkit.org for dom-modify:Runs
+           (where dom-modify is the name of the subtest and Runs is a metric in that subtest) for Dromaeo.
+        3. Platform - Mountain Lion, Qt, etc...
+
+        A user can select "all" for metric and platform but we disallow doing both at once since adding
+        all metrics on all platforms tends to add way too many charts and hang the browser. I also can't
+        think of a use case where you want to look at that many charts at once. We can support this later
+        if valid use cases come up.
+
+        * public/index.html:
+        (.showCharts.addOption): Extracted.
+        (.showCharts): Added "metricList" that shows the list of test and metrics (in the form of
+        relative metrics paths such as "DOMWalk:Time") for each top-level test selected in testList.
+        metricList has onchange handler that enables/disables items on platformList.
+        
+        (init): Sort tests and test metrics here instead of doing that in showCharts.
+
+2013-02-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13316756> SafariPerfMonitor: tooltip should include a link to build URLs
+
+        Reviewed by Remy Demarest and Ricky Mondello.
+
+        Added a hyperlink to build page in tooltips. Repeating the entire build URL in each build
+        was a bad idea because it bloats the resultant JSON file too much. So move the build URL
+        templates to the manifest file instead. Each build now only contains the builder id.
+
+        * public/api/runs.php: Removed the part of the query that joined builders table. This
+        speeds up the query quite a bit.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::generate): Generate builders field.
+        (ManifestGenerator::builders): Added. Returns an associative array of builder ids to an
+        associative array that contains name and its build URL template.
+
+        * public/index.html:
+        (.buildLabelWithLinks.linkifyIfNotNull): Renamed from linkifiedLabel. Take a label and url
+        instead of a revision since this function is used for revisions and build page URLs now.
+        (.buildLabelWithLinks): Include the linkified build number.
+
+        * public/js/helper-classes.js:
+        (TestBuild.builder): Added.
+        (TestBuild.buildNumber): Added.
+        (TestBuild.buildUrl): Returns the build URL. The variable name in the URL template has been
+        changed from %s to $buildNumber to be more descriptive and consistent with other URL templates.
+
+2013-02-27  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Tooltips interfere with user interactions
+
+        Rubber-stamped by Simon Fraser.
+
+        Disable tooltip on the dashboard page since graphs are too small to be useful there.
+        Also, show graphs for only 10 days by default as opposed to 20.
+        Finally, dismiss the hovering tooltip when mouse enters a "pinned" tooltip.
+
+        * public/index.html:
+        * public/js/helper-classes.js:
+
+2013-02-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Fix some serious typo. We're supposed to be using SHA-256, not SHA-1 to hash our passwords,
+        to be compatible with webkit-perf.appspot.com.
+
+        * public/admin/builders.php:
+        * public/api/report.php:
+
+2013-02-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed.
+
+        Add a missing constraint on builds table. For a given builder, there should be exactly
+        one build for a given build number.
+
+        Also add report_committed_at to reports table to record the time at which a given report
+        was processed and test_runs and run_iterations rows were committed into the database.
+
+        * database/config.json:
+        * public/api/report.php:
+
+2013-02-22  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Add more checks for empty SQL query results.
+
+        * public/include/manifest.php:
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        More build fixes on perf.webkit.org.
+
+        * public/api/runs.php: Make PostgreSQL happier.
+        * public/include/manifest.php: Don't assume we always have bug trackers.
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: index.html duplicates the code in PerfTestRuns to determine smallerIsBetter
+        and fix other miscellaneous UI bugs.
+
+        Rubber-stamped by Simon Fraser.
+
+        Removed the code to determine whether smaller value is better or not for a given test in index.html
+        in the favor of using that of PerfTestRuns.
+
+        * public/include/manifest.php: Fixed a typo.
+        * public/index.html:
+        (Chart):
+        (Chart.attachMainPlot): Fixed a bug to access previousPoint.left even when previousPoint is null.
+
+        * public/js/helper-classes.js:
+        (PerfTestRuns): Added EndAllocations, MaxAllocations, and MeanAllocations.
+
+        (PerfTestRuns.computeScalingFactorIfNeeded): When the mean is almost 10,000 units, we may end up
+        using 5 digits instead of 4, resulting in the use of scientific notations. Go up to the next unit
+        at roughly 2,000 units to avoid this.
+
+        (Tooltip.show): Show the tooltip even when the new content is identical to the previous content.
+        The only thing we can avoid is innerHTML.
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Another build fix. The path to node is /usr/local/bin/node, not /usr/bin/local/node
+
+        * public/include/evaluator.js:
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13267898> SafariPerfMonitor: Bug trackers should be configurable
+
+        Reviewed by Remy Demarest.
+
+        Made the list of bug trackers configurable. Namely, each bug tracker can be added in
+        admin/bug-trackers.php and can be associated with multiple repositories.
+
+        The association between bug trackers and repositories (such as WebKit, Safari, etc...) are used
+        to determine the set of bug trackers to show for a given set of blame lists.
+        e.g. if a test regressed due to a change in Safari, then we don't want to show WebKit Bugzilla as
+        a place to file bugs against the regression.
+F
+        * database/init-database.sql: Added bug_trackers and tracker_repositories.
+        Also drop those tables before creating them (note "DROP TABLE reports" was missing).
+
+        * public/admin/bug-trackers.php: Added. The administrative interface for adding and managing
+        bug trackers, namely associated repositories.
+
+        * public/include/admin-header.php: Added a link to bug-trackers.php
+        * public/include/manifest.php:
+        (ManifestGenerator::generate): Include the list of bug trackers in the manifest.
+        Also moved the code to fetch repositories table here from ManifestGenerator::repositories.
+
+        (ManifestGenerator::repositories):
+
+        (ManifestGenerator::bug_trackers): Added. Generates an associative array of bug trackers where
+        keys are names of bug trackers and values are associative arrays with keys 'new_bug_url' and
+        'repositories' where the latter contains the list of associated repository names.
+
+        * public/index.html:
+        (Chart): Takes bugTrackers as as argument.
+        (Chart.showTooltipWithResults): Removed the hard-coded list.
+        (init):
+        (init.addPlatformsToDashboard):
+        (init.showCharts.createChartFromListPair):
+        (init): Stores the list of bug trackers in the manifest to a local variable.
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        A follow up on the previous build fix. When using proc_open, we need to make evalulator.js executable.
+
+        * public/include/evaluator.js:
+
+2013-02-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Extract the code to generate tabular view in administrative pages
+
+        Reviewed by Remy Demarest.
+
+        Extracted AdministrativePage to share the code to generate a tabular view of data and a form to insert
+        new row into the database.
+
+        * public/admin/aggregators.php: Use AdministrativePage.
+        * public/admin/builders.php: Ditto.
+        * public/admin/repositories.php: Ditto.
+        * public/include/admin-header.php:
+        (AdministrativePage): Added.
+        (AdministrativePage::__construct): column_info is an associative array that maps a SQL column name
+        to an associative array that describes the column.
+            - editing_mode: Specifies the type of form ('text', 'url', or 'string') to show for this column.
+            - label: Human readable name of the column.
+            - pre_insertion: Signifies that this column exists only before the row is inserted. e.g. password
+              column exists only before we create password_hash column at the insertion time.
+
+        (AdministrativePage::name_to_titlecase): Converts an underscored lowercase name to a human readable
+        titlecase (e.g. new_bug is converted to New Bug).
+        (AdministrativePage::column_label): Obtains the label specified in column_info or titlecased column name.
+        (AdministrativePage::render_form_control_for_column): "Renders" a text form control such as input and
+        textarea for a given editing mode ('text', 'url', or 'string').
+        (AdministrativePage::render_table): Renders a whole SQL table after sorting rows by the specified column.
+        (AdministrativePage::render_form_to_add): Renders a form to insert new row.
+
+2013-02-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Some systems don't support r+. Use proc_open instead.
+
+        * public/api/report.php:
+
+2013-02-15  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Use the mean data series as supposed to upper or lower confidence bounds
+        when computing the y-axis of data points to show tooltips at.
+
+        * public/index.html:
+
+2013-02-15  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Removed .htaccess in favor of directly putting directives in httpd.conf.
+
+        * Install.md:
+        * public/.htaccess: Removed.
+
+2013-02-14  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed.
+
+        * public/include/manifest.php: Build fix. db is on this.
+        * public/js/statistics.js:
+        (Statistics.confidenceInterval): Added. An utility function for debugging purposes.
+
+2013-02-13  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13165667> SafariPerfMonitor doesn't work on perf.webkit.org (Part 2)
+
+        Reviewed by Anders Carlsson.
+
+        Rewrote and merged populate-from-report.js into report.php.
+
+        * database/config.json: Added a path to node.js.
+
+        * database/init-database.sql: Don't require unit to be always present since it's no longer used by the front end.
+        Once we land this patch and update the administrative pages, we can remove this column.
+
+        Also add a new reports table to store JSON reported by builders. We used to store everything in jobs table but
+        that table is going away once we remove the node.js backend.
+
+        * database/populate-from-report.js: Removed.
+        * public/api/report.php: Added.
+        (ReportProcessor):
+        (ReportProcessor.__construct):
+        (ReportProcessor.process):
+
+        (ReportProcessor.store_report_and_get_build_data): We store the report into the database as soon as it has been
+        verified to be submitted by a known builder.
+
+        (ReportProcessor.exit_with_error): Store the error message and details in the database if the report had been
+        stored. If not, then notify that to the client via 'failureStored' in the JSON response.
+        (ReportProcessor.resolve_build_id): Insert build and build_revisions rows if needed. We don't do this atomically
+        inside a transaction because there could be multiple reports for a single build, each containing results for
+        different tests.
+
+        (ReportProcessor.recursively_ensure_tests): Parse a tree of tests and insert tests and test_metrics rows as
+        needed. It also computes the metrics to aggregate and prepares values to commit via ensure_aggregated_metric,
+        add_values_to_commit, and add_values_for_aggregation.
+
+        (ReportProcessor.is_list_of_aggregators): When a metric is an aggregation, it contains an array of aggregator
+        names, e.g. ["Arithmetic", "Geometric"], instead of a dictionary of configuration types to their values,
+        e.g. {Time: {current: [1, 2, 3,]}}. This function detects the former. (Note that dictionary and list are both
+        array's in PHP).
+
+        (ReportProcessor.ensure_aggregated_metric): Create a metric with aggregator to add it to the list of metrics
+        to be aggregated in ReportProcessor.aggregate.
+
+        (ReportProcessor.add_values_for_aggregation): Called by test metrics with aggregated parent test metrics.
+
+        (ReportProcessor.aggregate): Compute results for aggregated metrics. Consider a matrix with rows representing
+        child tests and columns representing "iterations" for a given aggregated metrics M. Initially, we have values
+        given for each row (child metrics of M). This function extracts each column (iteration) via
+        test_value_list_to_values_by_iterations, and feeds it into evaluate_expressions_by_node to get aggregated values
+        for each column (iteration of M). Finally, it registers those aggregated values to be committed.
+
+        Note that we don't want to start a new node.js process for each aggregation, so we accumulate all values to be
+        aggregated in node.js in $expressions. Each entry in $expressions is a JSON string that contains code and
+        values to be aggregated. node.js gives us back a list of JSON strings that contain aggregated values.
+
+        (ReportProcessor.test_value_list_to_values_by_iterations): See above.
+        (ReportProcessor.evaluate_expressions_by_node): See above.
+
+        (ReportProcessor.compute_caches): Compute cached mean, sum, and square sums for each run we're about to add
+        using evaluate_expressions_by_node. We can't do this before computing aggregated results since those aggregated
+        results also need the said caches.
+
+        (ReportProcessor.add_values_to_commit):
+
+        (ReportProcessor.commit): Add test_runs and run_iterations atomically inside a transaction, rolling back
+        the transaction as needed if anything goes wrong.
+
+        (ReportProcessor.rollback_with_error)
+        (main):
+        * public/include/db.php:
+        (Database.prepare_params): Use $values (instead of $placeholders) to compute the current index since
+        placeholders ($1, $2, etc...) may be split up into multiple arrays given they may not necessarily show up
+        contiguously in a SQL statement.
+
+        (Database.select_or_insert_row): Added. Selects a row if the attempt to insert the same row fails. It
+        automatically creates a query string from a dictionary of unprefixed column names and table. It returns
+        a column value of the choice.
+
+        (Database.begin_transaction): Added.
+        (Database.commit_transaction): Added.
+        (Database.rollback_transaction): Added.
+
+        * public/include/evaluator.js: Added.
+        * public/include/json-header.php:
+        (exit_with_error): Take error details and merge it with "additional details". This allows report.php to provide
+        context under which the request failed.
+        (successful_exit): Merge "additional details".
+        (set_exit_detail): Added. Sets "additional details" to the JSON returned by exit_with_error or successful_exit.
+        (merge_additional_details):
+
+2013-02-12  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Add more helper functions to db.php
+
+        Reviewed by Remy Demarest.
+
+        Added Database::insert_row and array_get to make common database operations easier.
+
+        * public/admin/aggregators.php: Use Database::insert_row instead of
+        execute_query_and_expect_one_row_to_be_affected.
+
+        * public/admin/builders.php: Ditto.
+
+        * public/admin/tests.php: Ditto; We used to run a separate SELECT query just to get the id after
+        inserting a row. With insert_row, we don't need that.
+
+        * public/include/admin-header.php: Ditto.
+
+        * public/include/db.php:
+        (array_get): Added. It returns the value of an array given a key if the key exists; otherwise
+        return the default value (defaults to NULL) if the key doesn't exist.
+
+        (Database::column_names): Added. Prefixes an array of column names and creates a comma separated
+        list of the names.
+
+        (Database::prepare_params): Added. Takes an associative array of column names and their values,
+        and builds up arrays for placeholder (e.g. $1, $2, etc...) and values, then returns an array of
+        column names all in the same order.
+
+        (Database::insert_row): Added. Inserts a new row into the specified table where column names have
+        the given prefix. Values are given in a form of an associative array where keys are unprefixed
+        column names and values are corresponding values. When the row is successfully inserted, it returns
+        the specified column's value (defaults to prefix_id). If NULL is specified, it returns a boolean
+        indicating the success of the insertion.
+
+2013-02-11  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13165667> SafariPerfMonitor doesn't work on perf.webkit.org (Part 1)
+
+        Reviewed by Conrad Shultz.
+
+        Rewrote the manifest generator in PHP.
+
+        * database/generate-manifest.js: Removed.
+        * public/admin/regenerate-manifest.php: Added. Use ManifestGenerator to generate and store the manifest.
+        * public/include/db.php:
+        (array_ensure_item_has_array): Added.
+        * public/include/evaluator.js: Added.
+        * public/include/json-header.php:
+        * public/include/manifest.php: Added.
+
+2013-02-11  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Dates on overflow plot are overlapping
+
+        Rubber-stamped by Simon Fraser.
+
+        Don't show more than 5 days.
+
+        * public/index.html:
+        * public/js/helper-classes.js:
+        (TestBuild.UTCtoPST):
+        (TestBuild.now):
+
+2013-02-07  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Show build time as well as commit time on the dashboard and tooltips.
+
+        Rubber-stamped by Simon Fraser.
+
+        Include both the maximum commit time and build time in buildLabelWithLinks.
+        Also use ISO format to save the screen real estate.
+
+        * public/index.html:
+        (buildLabelWithLinks):
+        * public/js/helper-classes.js:
+        (TestBuild):
+        (TestBuild.buildTime):
+        (TestBuild.formattedBuildTime):
+
+2013-02-08  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed; Convert metric.name to metric.unit in the front end.
+
+        * public/js/helper-classes.js:
+
+2013-02-07  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13166276> SafariPerfMonitor: Need hyperlinks to file bugs
+
+        Rubber-stamped by Simon Fraser.
+
+        This patch adds hyperlinks to file new bugs on Radar and WebKit Bugzilla. Because we want to include information
+        such as the degree of progression or regression and the regression ranges when filing new bugs, broke various
+        label() functions into smaller pieces to be used in both generating tooltips and the hyperlinks.
+
+        * public/index.html:
+        (.buildLabelWithLinks): Extracted from TestBuild.label.
+        (.showTooltipWithResults): Extracted from Tooltip.show. Also added the code to generate hyperlinks to file new bugs
+        on Radar and WebKit Bugzilla.
+        * public/js/helper-classes.js:
+        (PerfTestResult.metric): Replaced test() as runs.test() no longer exists.
+        (PerfTestResult.isBetterThan): Added.
+        (PerfTestResult.formattedRelativeDifference): Extracted from PerfTestResult.label.
+        (PerfTestResult.formattedProgressionOrRegression): Ditto. Also use "better" and "worse" instead of arrow symbols
+        to indicate progressions or regressions.
+        (PerfTestResult.label):
+        (TestBuild.formattedTime): Added.
+        (TestBuild.platform): Added.
+        (TestBuild.formattedRevisions): Extracted from TestBuild.label. Merged a part of linkifyLabel.
+        (TestBuild.smallerIsBetter): Added.
+        (Tooltip.show): Take a raw markup instead of two results.
+
+2013-02-06  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13151520> SafariPerfMonitor: Dashboard can cause excessive horizontal scrolling when there are many platforms
+
+        Rubber-stamped by Tim Horton.
+
+        Stack platforms when there are more than 3 of them since making the layout adaptive is tricky
+        since each platform may have a different number of tests to be shown on the dashboard.
+
+        * public/index.html:
+
+2013-02-05  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Don't prefix a SVn revision with 'r' when constructing a changeset / blame URL.
+
+        * public/js/helper-classes.js:
+        (TestBuild.label):
+
+2013-02-05  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: repository names or revisions are double-quoted when they contain a space
+
+        Rubber-stamped by Tim Horton.
+
+        The bug was in the PHP code that parsed Postgres array. Trim double quotations as needed.
+
+        Also fixed a bug in TestBuild where we used to show the revision range as r1234-1250 when
+        the revision r1234 was the revision used in the previous build.
+
+        * public/api/runs.php:
+        (parse_revisions_array): Trim double quotations around repository names and revisions.
+        * public/js/helper-classes.js:
+        (TestBuild.label):
+
+2013-02-05  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13151558> SafariPerfMonitor: Tooltip is unusable
+
+        Rubber-stamped by Tim Horton.
+
+        * public/index.html:
+        (Chart.attachMainPlot): Disable auto highlighting (circle around a data point that shows up on hover)
+        on the dashboard page as it's way too noisy.
+
+        (Chart.hideTooltip): Added. Hides the tooltip that shows up on hover.
+
+        (.toggleClickTooltip): Extracted from the code for "mouseout" bind (now replaced by "mouseleave").
+        Pins or unpins a tooltip. When pinning a tooltip, we create a tooltip behind the scene and show that
+        so that the tooltip for hover can be reused further.
+
+        (.closestItemForPageX): Find the closest item given pageX. We iterate data points from left to right,
+        and find the first point that lies on the right of the cursor position. We then compute the midpoint
+        between this and the previous point and pick the closer of the two. It returns an item-like object
+        that has all properties we need since flot doesn't provide an API to retrieve the real item object.
+
+        (Chart): Call toggleClickTooltip when a (hover) tooltip is clicked.
+
+        (Chart.attach): In "plothover" bind, call closestItemForPageX when item is not provided by flot on
+        the first or "current" data points (as opposed to target or baseline data points).
+
+        Also bind the code to clear crosshair and hide tooltips to "mouseleave" instead of "mouseout", and
+        avoid triggering this code when the cursor is still within the plot's rectangle (e.g. when a cursor
+        moves onto a tooltip) to avoid the premature dismissal of a tooltip.
+
+        * public/js/helper-classes.js:
+        (Tooltip.ensureContainer): Don't automatically close then the user clicks on tooltip. Delegate this
+        work to the client via bindClick.
+
+        (Tooltip.show): Move tooltip up by 5px. Also added a FIXME to move this offset computation to the client.
+
+        (Tooltip.bindClick): Added.
+
+2013-02-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Yet another build fix. metricId*s*.
+
+        * public/admin/tests.php:
+
+2013-02-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Another build fix. Use the new payload format for the aggregate job.
+
+        * public/admin/tests.php:
+
+2013-02-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fixes.
+
+        * database/aggregate.js: Use variables that actually exist.
+        * database/database-common.js:
+        (ensureConfigurationIdFromList): Add the newly added configuration to the list so that subsequent
+        function calls will find this configuration.
+
+2013-01-31  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13130139> SafariPerfMonitor: Add ReadMe
+
+        Reviewed by Ricky Mondello.
+
+        Turned InstallManual into a proper markdown document and added ReadMe.md.
+
+        * InstallManual: Removed.
+        * InstallManual.md: Moved from InstallManual.
+        * ReadMe.md: Added.
+
+2013-01-31  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13109335> SafariPerfMonitor: Add baseline and target lines
+
+        Reviewed by Ricky Mondello.
+
+        This patch prepares the front end code to process baseline and target results properly.
+
+        * public/index.html:
+        (fetchTest.createRunAndResults): Extracted.
+        (fetchTest): Call createRunAndResults on current, baseline, and target values of the JSON.
+        Deleted the comment about how sorting will be unnecessary once we start results in the server side
+        since sorting by the maximum revision commit time turned out to be non-trivial in php.
+
+2013-01-29  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13057071> SafariPerfMonitor: Use newer version of flot that supports timezone properly
+
+        Reviewed by Tim Horton.
+
+        Use flot at https://github.com/flot/flot/commit/ec168da2cb8619ebf59c7e721d12c44a7960ff41.
+        These files are "dynamically linked" to our app.
+
+        * public/index.html:
+        * public/js/jquery-1.8.2.min.js: Removed.
+        * public/js/jquery.colorhelpers.js: Added.
+        * public/js/jquery.flot.categories.js: Added.
+        * public/js/jquery.flot.crosshair.js: Added.
+        * public/js/jquery.flot.errorbars.js: Added.
+        * public/js/jquery.flot.fillbetween.js: Added.
+        * public/js/jquery.flot.js: Added.
+        * public/js/jquery.flot.min.js: Removed.
+        * public/js/jquery.flot.navigate.js: Added.
+        * public/js/jquery.flot.resize.js: Added.
+        * public/js/jquery.flot.selection.js: Added.
+        * public/js/jquery.flot.stack.js: Added.
+        * public/js/jquery.flot.symbol.js: Added.
+        * public/js/jquery.flot.threshold.js: Added.
+        * public/js/jquery.flot.time.js: Added.
+        * public/js/jquery.js: Added.
+
+2013-01-29  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Return NaN instead of throwing when there aren't enough samples.
+
+        Reviewed by Sam Weinig.
+
+        It's better to return NaN when we don't have enough samples so that we can treat it
+        as if we don't have any confidence interval.
+
+        * public/js/statistics.js:
+        (Statistics.new):
+
+2013-01-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Apparently Safari sometimes appends / at the end of hash location. Remove that.
+
+        * public/js/helper-classes.js:
+        (URLState.parseIfNeeded):
+
+2013-01-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13081582> SafariPerfMonitor: Always use parameterized SQL functions in php code
+
+        Reviewed by Ricky Mondello.
+
+        Parameterized execute_query_and_expect_one_row_to_be_affected and updated the code accordingly.
+
+        * public/admin/aggregators.php: Use heredoc.
+        * public/admin/builders.php:
+        * public/admin/jobs.php:
+        * public/admin/repositories.php:
+        * public/admin/tests.php: Updated the forms to use unprefixed field names to match other pages.
+        This allows us to use update_field when updating test's url and metric's unit. Changed the action
+        to regenerate aggregated matrix from "update" to "add" to simplify the dependencies in if-else.
+        Also removed a stray code to update unit and url simultaneously since it's never used.
+        * public/include/admin-header.php:
+        (execute_query_and_expect_one_row_to_be_affected): Added $params. Also automatically convert
+        empty strings to NULL as it was previously done via $db->quote_string_or_null_if_empty in callers.
+        (update_field): Moved from repositories.php.
+        (add_job):
+        * public/include/db.php:
+        (quote_string_or_null_if_empty): Removed now that nobody uses this function.
+
+2013-01-25  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fixes. Treat mean, sum, and square sum as float, not int.
+
+        Also use 95% confidence interval instead of 90% confidence interval.
+
+        * public/api/runs.php:
+        * public/js/helper-classes.js:
+        (.this.confidenceIntervalDelta):
+
+2013-01-24  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Add an administrative page to edit repository information.
+
+        Reviewed by Ricky Mondello.
+
+        * public/admin/repositories.php: Added.
+        * public/include/admin-header.php:
+
+2013-01-23  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13067539> SafariPerfMonitor: Automatically create aggregated metrics from builder reports
+
+        Reviewed by Ricky Mondello.
+
+        Auto-create aggregated matrix such as arithmetic means and geometric means as requested and add a job
+        to aggregate results for those matrix in populate-from-report.js.
+
+        * database/generate-manifest.js:
+        (.): Include aggregator names such as Arithmetic and Geometric in the list of metrics.
+        * database/init-database.sql: Remove an erroneous unique constraint. There could be multiple matrix that share
+        the same test and name (e.g. Dromaeo, Time) with different aggregators (e.g. Arithmetic and Geometric).
+        * database/populate-from-report.js:
+        (main):
+        (getReport): No change even though the diff looks as if it moved.
+        (processReport): Extracted from main. Fetch the list of aggregators, pass that to recursivelyEnsureTestsIdsAndMetricsIds
+        to obtain the list of aggregated metrics (such as arithmetic means) that need to be passed to aggregate.js
+        (scheduleJobs): Extracted from processReport. Add a job to aggregate results.
+        (recursivelyEnsureTestsIdsAndMetricsIds): When a metric is a list of names, assume them as aggregator names,
+        and add corresponding metrics for them. Note we convert those names to ids using the dictionary we obtained
+        in processReport.
+        (ensureMetricId): Take an aggregator id as an argument.
+        * database/process-jobs.js: Support multiple metric ids and build id. Note that aggregate.js aggregates results
+        for all builds when the build id is not specified.
+        * public/admin/tests.php:
+        * public/index.html: Include the aggregator name in the full name since there could be multiple metrics
+        of the same name with different aggregators.
+
+2013-01-22  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Don't pass in arguments to in the wrong order.
+
+        * database/aggregate.js:
+
+2013-01-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/13057110> SafariPerfMonitor: x-axis is messed up
+
+        Reviewed by Ricky Mondello.
+
+        Since the version of flot we use doesn't support showing graphs in the current locate or
+        in a specific timezone, convert all timestamps to PST manually (Date's constructor will still
+        treat them as in UTC). We don't want to use the current locate because other websites on
+        webkit.org assume PST.
+
+        Also append this information to build's label.
+
+        * public/js/helper-classes.js:
+        (TestBuild):
+        (TestBuild.label):
+
+2013-01-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Store test URLs reported by builders.
+
+        Reviewed by Ricky Mondello.
+
+        * database/populate-from-report.js:
+        (recursivelyEnsureTestsIdsAndMetricsIds): Pass in the test url.
+        (ensureTestId): Store the URL.
+
+2013-01-20  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Yet another build fix; don't blow up even if we didn't have any test configurations.
+
+        * public/admin/tests.php:
+
+2013-01-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix; don't instantiate Date when a timestamp wasn't provided.
+
+        * database/populate-from-report.js:
+
+2013-01-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Rename SafariPerfDashboard to SafariPerfMonitor and add a install manual.
+
+        Reviewed by Tim Horton.
+
+        Added an install manual.
+
+        * InstallManual: Added.
+
+2012-12-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Minor build fix. Don't unset builderPassword when it's not set.
+
+        * public/api/report.php:
+
+2012-12-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Prettify JSON payloads and make very large payloads not explode the table in jobs.php.
+
+        Reviewed by Ricky Mondello.
+
+        * public/admin/admin.css: Make a very large payload scrollable.
+        * public/admin/jobs.php: Format JSONs.
+
+2012-12-19  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/12897424> SafariPerfMonitor: Add ability to report results from bots
+
+        Reviewed by Ricky Mondello.
+
+        Add report.php and populate-from-report.js that process JSON files submitted by builders.
+
+        * database/populate-from-report.js: Added.
+        (main):
+        (getReport): Obtains the payload (the actual report) from "jobs" table.
+        (recursivelyEnsureTestsIdsAndMetricsIds): "reports.tests" contain a tree of tests, test metrics,
+        and their results. This function recursively traverses tests and metrics and ensure their ids.
+        (ensureTestId):
+        (metricToUnit): Maps a metric name to a unit. This should really be done in the client side since
+        there is no point in storing unit given that every metric maps to exactly one unit (i.e. the mapping
+        is a "function" in mathematical sense).
+        (ensureMetricId):
+        (ensureRepositoryIdsForAllRevisions):
+        (getIdOrCreateBuildWithRevisions):
+        (ensureBuildIdAndRevisions): Obtains a build id given a builder name, a build number, and a build time
+        if one already exists. If not, then inserts a new build and corresponding revisions information (e.g.
+        build 123 may contain WebKit revision r456789). We don't retrieve rows for revisions since we don't use
+        it elsewhere.
+        (insertRun): Insert new rows into "test_runs" and "run_iterations" tables, thereby recording the new
+        test results all in a single transaction. This allows us to keep the database consistent in that either
+        a build has been reported or not at least in "test_runs" and "run_iterations" tables. It'll be ideal if
+        we could do the same for "builds" and "build_revisions" but that's not a hard requirement as far as
+        other parts of the application are concerned.
+        (scheduleQueriesToInsertRun):
+        * database/process-jobs.js: Add a call to populate-from-report.js.
+        * public/api/report.php: Added. Adds a new job named "report" to be processed by populate-from-report.js.
+        * public/include/db.php: Support parameterized query.
+        * public/include/json-header.php: Always include 'status' in the response so that builder submitting
+        a test result could confirm that the submission indeed succeeded.
+
+2012-12-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Rename get(Id)OrCreate*(Id) to ensure*Id as suggested by Ricky on one of his code reviews.
+
+        * database/aggregate.js:
+        * database/database-common.js:
+        (selectColumnCreatingRowIfNeeded):
+        (ensureRepositoryId):
+        (ensureConfigurationIdFromList):
+        * database/perf-webkit-migrator.js:
+        (.migrateStat.):
+        (.migrateStat):
+        (getOrCreateBuildId):
+
+2012-12-17  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Extract commonly-used functions from aggregate.js and perf-webkit-migrator.js.
+
+        Reviewed by Ricky Mondello.
+
+        As a preparation to add report.js that processes a JSON file submitted by bots, extract various functions
+        and classes from aggregate.js and perf-webkit-migrator.js to be shared.
+
+        * database/aggregate.js: Extracted TaskQueue and SerializedTaskQueue into utility.js.
+        (main):
+        (processBuild):
+        (saveAggregatedResults):
+        * database/database-common.js:
+        (getIdOrCreatePlatform): Extracted from webkit-perf-migrator.js.
+        (getIdOrCreateRepository): Ditto.
+        (getConfigurationsForPlatformAndMetrics): Renamed from fetchConfigurations. Extracted from aggregator.js.
+        (getIdFromListOrInsertConfiguration): Renamed from getOrInsertConfiguration. Extracted from aggregator.js.
+        * database/perf-webkit-migrator.js:
+        * database/utility.js: Added.
+        (TaskQueue): Extracted from aggregator.js. Fixed a bug that prevented tasks added after start() is called
+        from being executed.
+        (TaskQueue.startTasksInQueue): Execute remaining tasks without serializing them. If the queue is empty,
+        call the callback passed into start().
+        (TaskQueue.taskCallback): The function each task calls back. Decrement the counter and call statTasksInQueue.
+        (TaskQueue.addTask):
+        (TaskQueue.start):
+        (SerializedTaskQueue): Unlike TaskQueue, this class executes each task sequentially.
+        (SerializedTaskQueue.executeNextTask):
+        (SerializedTaskQueue.addTask):
+        (SerializedTaskQueue.start):
+
+2012-12-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Revert erroneously committed changes.
+
+        * database/config.json:
+
+2012-12-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        aggregator.js should be able to accept multiple metric ids and a single build id.
+
+        Reviewed by Ricky Mondello.
+
+        Make aggregator.js accept multiple ids and generate results for single build when bots are
+        reporting new results.
+
+        * database/aggregate.js:
+        (parseArgv): Added. Returns an object containing the parsed representation of argv,
+        which currently contains metricIDs and buildIds.
+        (main): Use parseArgv and processConfigurations
+        (processPlatform): Use build ids passed in or obtain all builds for the given platform.
+        (processPlatform.processConfigurations): Extracted.
+
+2012-12-17  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Add an administrative page for builders.
+
+        Reviewed by Ricky Mondello.
+
+        We need an administrative page to add and edit builder information.
+        Also renamed "slaves" to "builders" in order to reduce the amount of technical jargon we use.
+
+        * database/init-database.sql: Renamed slaves table to builders. Drop slave_os and slave_spec
+        since we don't have plans to use those columns in near future. Also make builder_name unique
+        as required by the rest of the app.
+        * public/admin/builders.php: Added.
+        * public/api/runs.php: Updated per the table rename.
+        * public/include/admin-header.php: Added a link to builders.php.
+
+2012-12-14  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fixes for r46982.
+
+        * database/aggregate.js:
+        (fetchConfigurations):  Bind i so that it's not always metricIds.length.
+        (fetchBuildsForPlatform): Return run_build as build_id since that's what caller expects.
+        (processBuild): Don't print "." until we've committed transactions. It's misleading.
+
+2012-12-13  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Unreviewed. Move some php files to public/include as suggested by Mark on a code review.
+
+        * public/admin/aggregators.php:
+        * public/admin/footer.php: Removed.
+        * public/admin/header.php: Removed.
+        * public/admin/index.php:
+        * public/admin/jobs.php:
+        * public/admin/tests.php:
+        * public/api/json-header.php: Removed.
+        * public/api/runs.php:
+        * public/db.php: Removed.
+        * public/include: Added.
+        * public/include/admin-footer.php: Copied from public/admin/footer.php.
+        * public/include/admin-header.php: Copied from public/admin/header.php.
+        * public/include/db.php: Copied from public/db.php.
+        * public/include/json-header.php: Copied from public/api/json-header.php.
+
+2012-12-13  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/12822613> SafariPerfMonitor: implement naive value aggregation mechanism
+
+        Reviewed by Ricky Mondello.
+
+        Added the initial implementation of value aggregation.
+        Also added abilities to configure the dashboard page in tests.php.
+
+        * database/aggregate.js: Added.
+        (TaskQueue): Added. Execute all tasks at once and waits for those tasks to complete.
+        (TaskQueue.addTask):
+        (TaskQueue.start):
+        (SerializedTaskQueue): Added. Execute tasks sequentially after one another until all of them are completed.
+        (SerializedTaskQueue.addTask):
+        (SerializedTaskQueue.start):
+        (main):
+        (processPlatform):
+        (fetchConfigurations):
+        (fetchBuildsForPlatform):
+        (processBuild):
+        (testsWithDifferentIterationCounts):
+        (aggregateIterationsForMetric): Retrieve run_iterations and aggregate results in memory.
+        (saveAggregatedResults): Insert into test_runs and test_config in transactions.
+        (getOrInsertConfiguration):
+        (fetchAggregators):
+        * database/database-common.js:
+        (fetchTable): Log an error as an error.
+        (getOrCreateId): Extracted from perf-webkit-migrator.
+        (statistics): Added.
+        * database/perf-webkit-migrator.js:
+        (migrateTestConfig): Converted units to respective metric names. Also removed the code to add jobs to update
+        runs JSON since runs JSONs are generated on demand now.
+        (migrateStat):
+        (getOrCreatePlatformId):
+        (getOrCreateTestId):
+        (getOrCreateConfigurationId):
+        (getOrCreateRevisionId):
+        (getOrCreateRepositoryId):
+        (getOrCreateBuildId):
+        * database/process-jobs.js:
+        (processJob): Handle 'aggregate' type.
+
+2012-12-11  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Fix the dashboard after adding test_metrics.
+
+        Reviewed by Ricky Mondello.
+
+        Rename test to metrics in various functions and sort tests on the charts page.
+        Also representing whether a test appears or not by setting a flag on dashboard
+        was bogus because test objects are shared by multiple platforms. Instead, store
+        dashboard platform list as intended by the manifest JSON.
+
+        * public/index.html:
+        (PerfTestRuns): Renamed test to metric.
+        (fetchTest): Ditto.
+        (showCharts): Ditto; also sort metrics' full names before adding them to the select element.
+        (fullName): Moved so that it appears above where it's called.
+        * public/js/helper-classes.js:
+
+2012-12-10  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Update tests.php to reflect recent changes in the database schema.
+
+        Reviewed by Conrad Shultz.
+
+        Made the following changes to tests.php:
+        1. Disallow adding metrics to tests without subtests.
+        2. Made dashboard configurable by adding checkboxes for each platform on each metric.
+        3. Linkified tests with subtests instead of showing all them at once.
+
+        * public/admin/admin.css:
+        (.action-field, .notice):
+        (label):
+        * public/admin/header.php: Specify paths by absolute paths so that tests.php can use PATH_INFO.
+        (execute_query_and_expect_one_row_to_be_affected): Return a boolean. Used in tests.php while adding test_metrics.
+        (add_job): Extracted.
+        * public/admin/tests.php: See above.
+        (array_item_set_default): Added.
+        (array_item_or_default): Renamed from get_value_with_default.
+        (compute_full_name): Extracted.
+        (sort_tests): Ditto.
+        (map_metrics_to_tests): Ditto.
+
+2012-12-06  Ryosuke Niwa  <rniwa@webkit.org>
+
+        <rdar://problem/12832324> SafariPerfMonitor: Linkify test names
+
+        Reviewed by Simon Fraser.
+
+        Linkify the headers using metric.test.url when it's provided.
+
+        * public/index.html:
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Use parameterized pg_query_params in query_and_fetch_all
+
+        Reviewed by Conrad Shultz.
+
+        Address a review comment by Mark by using pg_query_params instead of pg_query in query_and_fetch_all.
+
+        * public/api/runs.php:
+        * public/db.php:
+        (ctype_alnum_underscore): Added.
+
+2012-12-04  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Update the migration tool to support test_metrics.
+
+        Reviewed by Mark Rowe.
+
+        Updated the migration tool from webkit-perf.appspot.com to support test_metrics.
+        Also import run_iteration rows as runs JSON files now include individual values.
+
+        * database/database-common.js:
+        (addJob): Extracted.
+        * database/perf-webkit-migrator.js:
+        (migrateTestConfig): Interchange the order in which we fetch runs and add configurations
+        so that we can pass in the metric name and unit to getOrCreateConfigurationId.
+        (getOrCreateConfigurationId): Updated to add both test configuration and test metric.
+        (ensureCheckout):
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Build fix. Suppress "Undefined index" warning.
+
+        * public/admin/tests.php:
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Fix a commit error in r46756. api/ should obviously be added under public/
+
+        * api: Removed.
+        * api/json-header.php: Removed.
+        * api/runs.php: Removed.
+        * public/api: Copied from api.
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Linkify revisions and revisions range
+        <rdar://problem/12801010>
+
+        Reviewed by Mark Rowe.
+
+        Linkify revisions in TestBuild.label. Pass in manifest.repositories to TestBuild's constructor
+        since it needs to know "url" and "blameUrl".
+
+        Also tweaked the appearance of graphs on charts page to better align graphs when unit names are long.
+
+        * public/index.html:
+        * public/js/helper-classes.js:
+        (TestBuild):
+        (TestBuild.revision): Renamed from webkitRevision. Now returns an arbitrary revision number.
+        (TestBuild.label): Add labels for all revisions.
+        (TestBuild):
+        (.ensureContainer):
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Make the generation of "runs" JSON dynamic and support test_metrics.
+
+        Reviewed by Mark Rowe.
+
+        It turned out that we can fetch all runs for a given configuration in roughly 100-200ms.
+
+        Since there could be hundreds, if not thousands, of tests for each configuration and users
+        aren't necessarily interested in looking at all test results, it's much more efficient to
+        generate runs JSON dynamically (i.e. polling) upon a request instead of generating all of them
+        when bots report new results (i.e. pushing).
+
+        Rewrote the script to generate runs JSON in php and also supported test_metrics table.
+
+        * api: Added.
+        * api/json-header.php: Added. Sets Content-Type and cache policies (10 minutes by default).
+        (exit_with_error): Added.
+        (successful_exit): Added.
+        * api/runs.php: Added. Ported database/database-common.js. It's much shorter in php!
+        * database/generate-runs.js: Removed.
+        * database/process-jobs.js: No longer supports "runs".
+        * public/.htaccess: Added. Always add MultiView so that api/runs can receive a path info.
+        * public/db.php: Print "Nothing to see here." when it's accessed directly.
+        (ends_with): Added.
+        * public/index.html: Fetch runs JSONs from /api/runs/ instead of data/.
+
+2012-12-03  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Update tests.php and sample-data.sql per addition of test_metrics.
+
+        Rubber-stamped by Timothy Hatcher.
+
+        Remove a useless code from tests.php that used to update the unit and the url of a test
+        since it's no longer used, and add the UI and the ability to add a new aggregator to a test.
+
+        Also update the sample data to reflect the addition of test_metrics.
+
+        * database/sample-data.sql:
+        * public/admin/tests.php:
+
+2012-11-30  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Share more code between admin pages.
+
+        Reviewed by Timothy Hatcher.
+
+        Added notice and execute_query_and_expect_one_row_to_be_affected helper functions to share more code
+        between admin pages.
+
+        Also moved the code to connect to the database into header.php to be shared. Admin pages just need
+        to check the nullity of global $db now.
+
+        * public/admin/aggregators.php:
+        * public/admin/header.php:
+        (notice): Added
+        (execute_query_and_expect_one_row_to_be_affected): Added.
+        * public/admin/index.php:
+        * public/admin/jobs.php:
+        * public/admin/tests.php:
+
+2012-11-29  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Add admin page to edit aggregators
+        <rdar://problem/12782687>
+
+        Reviewed by Mark Rowe.
+
+        Add aggregators.php. It's very simple. We should probably share more code between various admin pages.
+
+        * public/admin/aggregators.php: Added.
+        * public/admin/header.php:
+        * public/admin/jobs.php: Removed an erroneous hidden input element.
+
+2012-11-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Fix a syntax error in init-database.sql and add the missing drop table at the beginning.
+
+        * database/init-database.sql:
+
+2012-11-28  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Allow multiple metrics per test
+        <rdar://problem/12773506>
+
+        Rubber-stamped by Mark Rowe.
+
+        Introduce a new table test_metrics. This table represents metrics each test can have
+        such as time, memory allocation, frame rate as well as aggregation such as arithmetic mean
+        and geometric mean.
+
+        Updated admin/tests.php and index.html accordingly.
+
+        Also create few indexes based on postgres' "explain analysis" as suggested by Mark.
+
+        * database/generate-manifest.js:
+        (buildPlatformMapIfPossible):
+        * database/generate-runs.js:
+        (fetchRuns):
+        * database/init-database.sql:
+        * database/schema.graffle:
+        * public/admin/admin.css:
+        (table):
+        (tbody.odd):
+        * public/admin/tests.php:
+        * public/index.html:
+
+2012-11-27  Ryosuke Niwa  <rniwa@webkit.org>
+
+        SafariPerfMonitor: Improve the webkit-perf migration tool
+        <rdar://problem/12760882>
+
+        Reviewed by Mark Rowe.
+
+        Make the migrator tool skip runs when fetching runs failed since webkit-perf.appspot.com is unreliable
+        and we don't want to pause the whole importation process until the user comes back to decide whether
+        to retry or not.
+
+        Also place form controls next to each test in tests.php so that users don't have to scroll all the way
+        down to make modifications.
+
+        Finally, add unique constraint to (run_config, run_build) in test_runs table in order to optimize a query
+        of the form: "SELECT run_id FROM test_runs WHERE run_config = $1 AND run_build = $2",
+
+        * database/init-database.sql:
+        * database/perf-webkit-migrator.js:
+        (migrateTestConfig):
+        * database/schema.graffle:
+        * public/admin/admin.css:
+        (table):
+        * public/admin/tests.php:
+
+2012-11-16  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Create a new performance dashboard
+        <rdar://problem/12625582>
+
+        Rubber-stamped by Mark Rowe.
+
+        Add the initial implementation of the perf dashboard.
+
+        * database: Added.
+        * database/config.json: Added.
+        * database/database-common.js: Added.
+        (connect):
+        (fetchTable):
+        (manifestPath):
+        (pathToRunsJSON):
+        (pathToLocalScript):
+        (config):
+        * database/generate-manifest.js: Added.
+        (ensureProperty):
+        (buildTestMap):
+        (buildPlatformMapIfPossible):
+        (generateFileIfPossible):
+        * database/perf-webkit-migrator.js: Added.
+        * database/process-jobs.js: Added.
+        * database/sample-data.sql: Added.
+        * database/schema.graffle: Added.
+        * public: Added.
+        * public/admin: Added.
+        * public/admin/README: Added.
+        * public/admin/admin.css: Added.
+        * public/admin/footer.php: Added.
+        * public/admin/header.php: Added.
+        * public/admin/index.php: Added.
+        * public/admin/jobs.php: Added.
+        * public/admin/tests.php: Added.
+        * public/common.css: Added.
+        * public/data: Added.
+        * public/db.php: Added.
+        * public/index.html: Added.
+        * public/js: Added.
+        * public/js/helper-classes.js: Added.
+        * public/js/jquery-1.8.2.min.js: Added.
+        * public/js/jquery.flot.min.js: Added.
+        * public/js/jquery.flot.plugins.js: Added.
+        * public/js/shared.js: Added.
+        (fileNameFromPlatformAndTest):
+        * public/js/statistics.js: Added.
+
diff --git a/Websites/perf.webkit.org/Install.md b/Websites/perf.webkit.org/Install.md
new file mode 100644 (file)
index 0000000..37ba7c3
--- /dev/null
@@ -0,0 +1,79 @@
+# Checking Out the Code and Installing Required Applications
+
+Note: These instructions assume you're using Mac OS X Mountain Lion as the host server, and assume that we're installing
+this application at `/Volumes/Data/WebKitPerfMonitor`.
+
+1. Install Server (DO NOT launch the Server app)
+2. Install node.
+3. Install Xcode with command line tools (only needed for svn)
+4. `svn co https://svn.webkit.org/repository/webkit/trunk/Websites/perf.webkit.org /Volumes/Data/WebKitPerfMonitor`
+5. Inside `/Volumes/Data/WebKitPerfMonitor`, run `npm install pg`.
+
+
+# Configuring Apache
+
+Don't use the Server App to start or stop Apache. It does weird things to httpd configurations. Use apachectl instead:
+ - Starting httpd: `sudo apachectl stop`
+ - Stopping httpd: `sudo apachectl restart`
+
+## Edit /private/etc/apache2/httpd.conf
+
+1. Update ServerAdmin to your email address
+2. Change DocumentRoot to `/Volumes/Data/WebKitPerfMonitor/public/`
+3. Uncomment `"LoadModule php5_module libexec/apache2/libphp5.so"`
+4. Modify the directives for the document root and / to allow overriding `"All"`
+5. Delete directives on CGI-Executables
+6. Add the following directives to enable gzip:
+    
+        <IfModule mod_deflate.c>
+            AddOutputFilterByType DEFLATE text/html text/xml text/plain application/json application/xml application/xhtml+xml
+        </IfModule>
+
+7. Add the following directives to enable zlib compression and MultiViews on WebKitPerfMonitor/public:
+
+        Options Indexes MultiViews
+        php_flag zlib.output_compression on
+
+Note: If you've accidentally turned on the Server app, httpd.conf is located at `/Library/Server/Web/Config/apache2/` instead.
+Delete the Web Sharing related stuff and include `/private/etc/apache2/httpd.conf` at the very end.
+
+The log is located at `/private/var/log/apache2`.
+
+
+# Protecting the Administrative Pages to Prevent Execution of Arbitrary Code
+
+By default, the application gives the administrative privilege to everyone. Anyone can add, remove, or edit tests,
+builders, and other entities in the database and may even execute arbitrary JavaScript on the server via aggregators.
+
+We recommend protection via Digest Auth on https connection.
+
+Generate a password file via `htdigest -c <path> <realm> <username>`, and then create admin/.htaccess with:
+
+       AuthType Digest
+       AuthName "<Realm>"
+       AuthDigestProvider file
+       AuthUserFile "<Realm>"
+       Require valid-user
+
+where <Realm> is replaced with the realm of your choice.
+
+
+# Configuring PostgreSQL
+
+1. Create database: `/Applications/Server.app/Contents/ServerRoot/usr/bin/initdb /Volumes/Data/WebKitPerfMonitor/PostgresSQL`
+2. Start database:
+   `/Applications/Server.app/Contents/ServerRoot/usr/bin/pg_ctl -D /Volumes/Data/WebKitPerfMonitor/PostgresSQL
+   -l logfile -o "-k /Volumes/Data/WebKitPerfMonitor/PostgresSQL" start`
+
+## Creating a Database and a User
+
+1. Create a database: `createdb webkit-perf-db -h localhost`
+2. Create a user: `createuser -P -S -e webkit-perf-db-user -h localhost`
+3. Connect to database: `psql webkit-perf-db -h localhost`
+4. Grant all permissions to the new user: `grant all privileges on database "webkit-perf-db" to "webkit-perf-db-user";`
+5. Update database/config.json.
+
+## Initializing the Database
+
+Run `database/init-database.sql` in psql as `webkit-perf-db-user`:
+`psql webkit-perf-db -h localhost --username webkit-perf-db-user -f database/init-database.sql`
diff --git a/Websites/perf.webkit.org/ReadMe.md b/Websites/perf.webkit.org/ReadMe.md
new file mode 100644 (file)
index 0000000..0dc02fc
--- /dev/null
@@ -0,0 +1,141 @@
+# Concepts
+
+## Platform
+
+A platform is an environmental configuration under which performance tests run. This is typically
+an operating system such as Lion, Mountain Lion, or Windows 7.
+
+## Builder
+
+A builder is a physical machine that submits a result of one or more tests to one or more platforms.
+Each builder should have a password it uses to submit the results to this application, and it may also
+have a URL associated with it.
+
+## Build
+
+A build is a single run of tests on a given builder. It's possible for a single build to have ran multiple
+tests on multiple platforms.
+
+## Test Metric
+
+A test metric is a type of measurement a test makes. A single test may measure multiple metrics such as
+Time (ms), Malloc (bytes), and JSHeap (bytes). The mapping from metrics to units is a function
+(in mathematical sense).
+
+## Test Configuration
+
+A test configuration is a combination of a test metric, a platform, and a configuration type: "current",
+"baseline", or "target". With metric, configuration creates a three-level tree structure under a test as follows:
+
+- MyTest (Test 1)
+    - Time (Metric 1)
+        - Lion : current (Configuration 1)
+        - Lion : baseline (Configuration 2)
+        - Lion : target (Configuration 3)
+        - Mountain Lion : current (Configuration 4)
+        - Mountain Lion : baseline (Configuration 5)
+        - Mountain Lion : target (Configuration 6)
+    - Malloc (Metric 2)
+        - Lion : current (Configuration 7)
+        - Lion : baseline (Configuration 8)
+        - Lion : target (Configuration 9)
+        - Mountain Lion : current (Configuration 10)
+        - Mountain Lion : baseline (Configuration 11)
+        - Mountain Lion : target (Configuration 12)
+- AnotherTest (Test 2)
+    - Time (Metric 3)
+        - Lion : current (Configuration 13)
+        - Mountain Lion : current (Configuration 14)
+
+## Run and Iteration
+
+A run is a ordered list of values obtained for a single configuration on a single build. For example, a Lion
+builder may execute MyTest 10 times, i.e. 10 iterations, and create a single run after computing the arithmetic
+mean of 10 values obtained in this process. Each run has associated iterations, which represents an individual
+measurement of the same configuration (of a single test metric) in the run.
+
+## Aggregation and Aggregator
+
+Aggregation is a process by which a test with child tests synthetically generates results for itself using
+results of sub tests. For example, we may have a page loading test (PageLoadingTest), which loads
+www.webkit.org and www.mozilla.org as follows:
+
+- PageLoadingTest (Test 1)
+    - www.webkit.org (Test 2)
+    - www.mozilla.org (Test 3)
+
+(Note that PageLoadingTest, www.webkit.org, and www.mozilla.org each has its own metrics and configurations,
+which are not shown here.)
+
+Then results for a metric, e.g. Time, of PageLoadingTest could be generated from results of the same metric in
+subtests, namely www.webkit.org and www.mozilla.org. The process is called "aggregation", and the exact nature of
+the aggregation is defined in terms of an aggregator. All aggregators are written in JavaScript.
+
+The aggregator for arithmetic mean could be implemented as:
+    
+    values.reduce(function (a, b) { return a + b; }) / values.length;
+
+When a builder reports a result JSON to the application, the background process automatically schedules a job
+to aggregate results for all tests specified in the JSON. The aggregation can also be triggered manually on
+`/admin/tests`.
+
+Reporting Results
+=================
+
+To submit the results of a new test to an instance of the app, you need the following:
+
+ - A builder already added on `/admin/builders`
+ - A script that submits a JSON payload of the supported format via a HTTP/HTTPS request to `/api/report`
+
+JSON Format
+-----------
+
+The JSON submitted to `/api/report` should be an array of dictionaries, each of which should
+contain the following key-value pairs representing a single run of tests on a single build:
+
+- `builderName` - The name of a builder present on `/admin/builders`.
+- `builderPassword` - The password associated with the builder.
+- `buildNumber` - The string that uniquely identifies a given build on the builder.
+- `buildTime` - The time at which this build started in **UTC** (Use ISO time format such as
+   2013-01-31T22:22:12.121051). This is completely independent of timestamp of repository revisions.
+- `platform` - The human-readable name of a platform such as `Mountain Lion` or `Windows 7`.
+- `revisions` - A dictionary that maps a repository name to a dictionary with "revision" and optionally
+   "timestamp" as keys each of which maps to, respectively, the revision in **string** associated with
+   the build and the times at which the revision was committed to the repository respectively.
+   e.g. `{"WebKit": {"revision": "123", "timestamp": "2001-09-10T17:53:19.000000Z"}}`
+- `tests` - A dictionary that maps a test name to a dictionary that represents a test. The value of a test
+   itself is a dictionary with the following keys:
+    - `metrics` - A dictionary that maps a metric name to a dictionary of configuration types to an array of
+      iteration values. e.g. `{"Time": {"current": [629.1, 654.8, 598.9], "target": [544, 585.1, 556]}}`
+      When a metric represents an aggregated value, it should be an array of aggregator names instead. e.g.
+      `{"Time": ["Arithmetic", "Geometric"]}` **This format may change in near future**.
+    - `url` - The URL of the test. This value should not change over time as only the latest value is stored
+        in the application.
+    - `tests` - A dictionary of tests; the same format as this dictionary.
+
+A sample JSON:
+
+    {
+        "buildNumber": "651",
+        "buildTime": "2013-01-31T22:22:12.121051",
+        "builderName": "bot-111",
+        "builderPassword": "********"
+        "platform": "Mountain Lion",
+        "revisions": {
+            "OS X": {"revision": "10.8.2"},
+            "WebKit": {"revision": "141469", "timestamp": "2013-01-31T20:55:15.452267Z"}
+        },
+               "tests": {
+            "PageLoadingTest": {
+                "metrics": {"Time": ["Arithmetic", "Geometric"]},
+                "tests": {
+                    "webkit.org": {
+                    "metrics": {"Time": {"current": [629.1, 654.8, 598.9]}}
+                },
+                "url": "http://www.webkit.org/"
+            }
+        }
+    }
+
+
+FIXME: Add a section describing how the application is structured.
diff --git a/Websites/perf.webkit.org/config.json b/Websites/perf.webkit.org/config.json
new file mode 100644 (file)
index 0000000..486664f
--- /dev/null
@@ -0,0 +1,16 @@
+{
+    "debug": true,
+    "jsonCacheMaxAge": 600,
+    "JSONDirectory": "../public/data/",
+    "database": {
+        "host": "localhost",
+        "port": "5432",
+        "username": "webkit-perf-db-user",
+        "password": "password",
+        "name": "webkit-perf-db"
+    },
+    "testServer": {
+        "hostname": "localhost",
+        "port": 80
+    }
+}
diff --git a/Websites/perf.webkit.org/init-database.sql b/Websites/perf.webkit.org/init-database.sql
new file mode 100644 (file)
index 0000000..554ad44
--- /dev/null
@@ -0,0 +1,130 @@
+DROP TABLE run_iterations CASCADE;
+DROP TABLE test_runs CASCADE;
+DROP TABLE test_configurations CASCADE;
+DROP TYPE test_configuration_type CASCADE;
+DROP TABLE aggregators CASCADE;
+DROP TABLE build_revisions CASCADE;
+DROP TABLE builds CASCADE;
+DROP TABLE builders CASCADE;
+DROP TABLE repositories CASCADE;
+DROP TABLE platforms CASCADE;
+DROP TABLE test_metrics CASCADE;
+DROP TABLE tests CASCADE;
+DROP TABLE jobs CASCADE;
+DROP TABLE reports CASCADE;
+DROP TABLE tracker_repositories CASCADE;
+DROP TABLE bug_trackers CASCADE;
+
+CREATE TABLE platforms (
+    platform_id serial PRIMARY KEY,
+    platform_name varchar(64) NOT NULL,
+    platform_hidden boolean NOT NULL DEFAULT FALSE);
+
+CREATE TABLE repositories (
+    repository_id serial PRIMARY KEY,
+    repository_name varchar(64) NOT NULL,
+    repository_url varchar(1024),
+    repository_blame_url varchar(1024));
+
+CREATE TABLE bug_trackers (
+    tracker_id serial PRIMARY KEY,
+    tracker_name varchar(64) NOT NULL,
+    tracker_new_bug_url varchar(1024));
+
+CREATE TABLE tracker_repositories (
+    tracrepo_tracker integer NOT NULL REFERENCES bug_trackers ON DELETE CASCADE,
+    tracrepo_repository integer NOT NULL REFERENCES repositories ON DELETE CASCADE);
+
+CREATE TABLE builders (
+    builder_id serial PRIMARY KEY,
+    builder_name varchar(64) NOT NULL UNIQUE,
+    builder_password_hash character(64) NOT NULL,
+    builder_build_url varchar(1024));
+
+CREATE TABLE builds (
+    build_id serial PRIMARY KEY,
+    build_builder integer REFERENCES builders ON DELETE CASCADE,
+    build_number integer NOT NULL,
+    build_time timestamp NOT NULL,
+    build_latest_revision timestamp,
+    CONSTRAINT builder_build_time_tuple_must_be_unique UNIQUE(build_builder, build_number, build_time));
+CREATE INDEX build_builder_index ON builds(build_builder);
+
+CREATE TABLE build_revisions (
+    revision_build integer NOT NULL REFERENCES builds ON DELETE CASCADE,
+    revision_repository integer NOT NULL REFERENCES repositories ON DELETE CASCADE,
+    revision_value varchar(64) NOT NULL,
+    revision_time timestamp,
+    PRIMARY KEY (revision_repository, revision_build));
+CREATE INDEX revision_build_index ON build_revisions(revision_build);
+CREATE INDEX revision_repository_index ON build_revisions(revision_repository);
+
+CREATE TABLE aggregators (
+    aggregator_id serial PRIMARY KEY,
+    aggregator_name varchar(64),
+    aggregator_definition text);
+
+CREATE TABLE tests (
+    test_id serial PRIMARY KEY,
+    test_name varchar(255) NOT NULL,
+    test_parent integer REFERENCES tests ON DELETE CASCADE,
+    test_url varchar(1024) DEFAULT NULL,
+    CONSTRAINT parent_test_must_be_unique UNIQUE(test_parent, test_name));
+
+CREATE TABLE test_metrics (
+    metric_id serial PRIMARY KEY,
+    metric_test integer NOT NULL REFERENCES tests ON DELETE CASCADE,
+    metric_name varchar(64) NOT NULL,
+    metric_aggregator integer REFERENCES aggregators ON DELETE CASCADE);
+
+CREATE TYPE test_configuration_type as ENUM ('current', 'baseline', 'target');
+CREATE TABLE test_configurations (
+    config_id serial PRIMARY KEY,
+    config_metric integer NOT NULL REFERENCES test_metrics ON DELETE CASCADE,
+    config_platform integer NOT NULL REFERENCES platforms ON DELETE CASCADE,
+    config_type test_configuration_type NOT NULL,
+    config_is_in_dashboard boolean NOT NULL DEFAULT FALSE,
+    CONSTRAINT configuration_must_be_unique UNIQUE(config_metric, config_platform, config_type));
+CREATE INDEX config_platform_index ON test_configurations(config_platform);
+
+CREATE TABLE test_runs (
+    run_id serial PRIMARY KEY,
+    run_config integer NOT NULL REFERENCES test_configurations ON DELETE CASCADE,
+    run_build integer NOT NULL REFERENCES builds ON DELETE CASCADE,
+    run_iteration_count_cache smallint,
+    run_mean_cache double precision,
+    run_sum_cache double precision,
+    run_square_sum_cache double precision,
+    CONSTRAINT test_config_build_must_be_unique UNIQUE(run_config, run_build));
+CREATE INDEX run_config_index ON test_runs(run_config);
+CREATE INDEX run_build_index ON test_runs(run_build);
+
+CREATE TABLE run_iterations (
+    iteration_run integer NOT NULL REFERENCES test_runs ON DELETE CASCADE,
+    iteration_order smallint NOT NULL CHECK(iteration_order >= 0),
+    iteration_group smallint CHECK(iteration_group >= 0),
+    iteration_value double precision,
+    iteration_relative_time float,
+    PRIMARY KEY (iteration_run, iteration_order));
+
+CREATE TABLE jobs (
+    job_id serial PRIMARY KEY,
+    job_type varchar(64) NOT NULL,
+    job_created_at timestamp NOT NULL DEFAULT NOW(),
+    job_started_at timestamp,
+    job_started_by_pid integer,
+    job_completed_at timestamp,
+    job_attempts integer NOT NULL DEFAULT 0,
+    job_payload text,
+    job_log text);
+
+CREATE TABLE reports (
+    report_id serial PRIMARY KEY,
+    report_builder integer NOT NULL REFERENCES builders ON DELETE RESTRICT,
+    report_build_number integer,
+    report_build integer REFERENCES builds,
+    report_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
+    report_committed_at timestamp,
+    report_content text,
+    report_failure varchar(64),
+    report_failure_details text);
diff --git a/Websites/perf.webkit.org/public/admin/README b/Websites/perf.webkit.org/public/admin/README
new file mode 100644 (file)
index 0000000..11b5d26
--- /dev/null
@@ -0,0 +1 @@
+This directory should be password-protected unless you want anyone to be able to mess with the database.
diff --git a/Websites/perf.webkit.org/public/admin/admin.css b/Websites/perf.webkit.org/public/admin/admin.css
new file mode 100644 (file)
index 0000000..3acc97b
--- /dev/null
@@ -0,0 +1,54 @@
+table {
+    font-size: small;
+}
+
+table, td {
+    border-collapse: collapse;
+    border: solid 1px #ccc;
+}
+
+td {
+    padding: 5px;
+}
+
+td pre {
+    max-height: 30em;
+    overflow: scroll;
+    margin: 0;
+    padding: 0;
+}
+
+tbody.odd {
+    background: #f6f6f6;
+}
+
+.action-field, .notice {
+    min-width: 50ex;
+    display: inline-block;
+    margin: 1em 0px;
+    margin-right: 1em;
+    border: solid 1px #ccc;
+    border-radius: 5px;
+    padding: 5px;
+}
+
+.action-field h2 {
+    font-size: 1em;
+    font-weight: normal;
+    padding: 0;
+    margin: 0 0 1em 0;
+}
+
+form {
+    display: inline;
+}
+
+label {
+    display: inline-block;
+}
+
+pre {
+    white-space: pre-wrap;
+    word-wrap: break-word;
+    word-break: break-all;
+}
\ No newline at end of file
diff --git a/Websites/perf.webkit.org/public/admin/aggregators.php b/Websites/perf.webkit.org/public/admin/aggregators.php
new file mode 100644 (file)
index 0000000..8c52e21
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+include('../include/admin-header.php');
+
+if ($db) {
+
+    if ($action == 'add') {
+        if (array_key_exists('name', $_POST) && array_key_exists('definition', $_POST)) {
+            if ($db->insert_row('aggregators', 'aggregator', array('name' => $_POST['name'], 'definition' => $_POST['definition'])))
+                notice('Inserted the new aggregator');
+            else
+                notice('Could not add the aggregator');
+        } else
+            notice('Invalid parameters.');
+    } else if ($action == 'update') {
+        if (!update_field('aggregators', 'aggregator', 'name') && !update_field('aggregators', 'aggregator', 'definition'))
+            notice('Invalid parameters.');
+    }
+
+    $page = new AdministrativePage($db, 'aggregators', 'aggregator', array(
+        'name' => array('editing_mode' => 'string'),
+        'definition' => array('editing_mode' => 'text'),
+    ));
+
+    $page->render_table('name');
+    $page->render_form_to_add();
+
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/bug-trackers.php b/Websites/perf.webkit.org/public/admin/bug-trackers.php
new file mode 100644 (file)
index 0000000..b25cd63
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+include('../include/admin-header.php');
+
+if ($db) {
+    if ($action == 'add') {
+        if ($db->insert_row('bug_trackers', 'tracker', array(
+            'name' => $_POST['name'], 'new_bug_url' => $_POST['new_bug_url']))) {
+            notice('Inserted the new bug tracker.');
+            regenerate_manifest();
+        } else
+            notice('Could not add the bug tracker.');
+    } else if ($action == 'update') {
+        if (update_field('bug_trackers', 'tracker', 'name') || update_field('bug_trackers', 'tracker', 'new_bug_url'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    } else if ($action == 'associate') {
+        $tracker_id = intval($_POST['id']);
+        $db->query_and_get_affected_rows("DELETE FROM tracker_repositories WHERE tracrepo_tracker = $1", array($tracker_id));
+
+        $suceeded = TRUE;
+        $tracker_repositories = array_get($_POST, 'tracker_repositories');
+        if ($tracker_repositories) {
+            foreach ($tracker_repositories as $repository_id) {
+                if (!$db->insert_row('tracker_repositories', 'tracrepo',
+                    array('tracker' => $tracker_id, 'repository' => $repository_id), NULL)) {
+                    $suceeded = TRUE;
+                    notice("Failed to associate repository $repository_id with tracker $tracker_id.");
+                }
+            }
+        }
+        if ($suceeded) {
+            notice('Updated the association.');
+            regenerate_manifest();
+        }
+    }
+
+    function associated_repositories($row) {
+        global $db;
+
+        $tracker_repositories = $db->query_and_fetch_all('SELECT * FROM repositories LEFT OUTER JOIN tracker_repositories
+            ON tracrepo_repository = repository_id AND (tracrepo_tracker = $1 OR tracrepo_tracker IS NULL)
+            ORDER BY repository_name', array($row['tracker_id']));
+
+        $content = <<< END
+<form method="POST"><input type="hidden" name="id" value="{$row['tracker_id']}">
+END;
+
+        foreach ($tracker_repositories as $repository) {
+            $id = intval($repository['repository_id']);
+            $name = htmlspecialchars($repository['repository_name']);
+
+            $checked = $repository['tracrepo_tracker'] ? ' checked' : '';
+            $content .= "<label><input type=\"checkbox\" name=\"tracker_repositories[]\" value=\"{$id}\"$checked>$name</label>";
+        }
+
+        $content .= <<< END
+<button type="submit" name="action" value="associate">Save</button></form>
+END;
+        return array($content);
+    }
+
+    $page = new AdministrativePage($db, 'bug_trackers', 'tracker', array(
+        'name' => array('editing_mode' => 'string'),
+        'new_bug_url' => array('editing_mode' => 'text', 'label' => 'New Bug URL ($title, $description)'),
+        'Associated repositories' => array('custom' => function ($row) { return associated_repositories($row); }),
+    ));
+
+    $page->render_table('name');
+    $page->render_form_to_add('New Bug Tracker');
+
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/builders.php b/Websites/perf.webkit.org/public/admin/builders.php
new file mode 100644 (file)
index 0000000..0761dbd
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+require('../include/admin-header.php');
+
+if ($db) {
+
+    if ($action == 'add') {
+        if ($db->insert_row('builders', 'builder', array(
+            'name' => $_POST['name'], 'password_hash' => hash('sha256', $_POST['password']), 'build_url' => array_get($_POST, 'build_url')))) {
+            notice('Inserted the new builder.');
+            regenerate_manifest();
+        } else
+            notice('Could not add the builder.');
+    } else if ($action == 'update') {
+        if (update_field('builders', 'builder', 'name') || update_field('builders', 'builder', 'build_url'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    }
+
+    $page = new AdministrativePage($db, 'builders', 'builder', array(
+        'name' => array('size' => 50, 'editing_mode' => 'string'),
+        'password_hash' => array(),
+        'password' => array('pre_insertion' => TRUE, 'editing_mode' => 'string'),
+        'build_url' => array('label' => 'Build URL', 'size' => 100, 'editing_mode' => 'url'),
+    ));
+
+    $page->render_table('name');
+    $page->render_form_to_add();
+}
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/index.php b/Websites/perf.webkit.org/public/admin/index.php
new file mode 100644 (file)
index 0000000..5baa46a
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+require('../include/admin-header.php');
+
+?><p><q>We ought never to allow ourselves to be persuaded of the truth of anything unless on the evidence of our reason.</q> - Ren&#xe9; Descartes</p><?php
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/jobs.php b/Websites/perf.webkit.org/public/admin/jobs.php
new file mode 100644 (file)
index 0000000..d62d86c
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+require('../include/admin-header.php');
+
+if ($action == 'delete') {
+    if (array_key_exists('id', $_POST)) {
+        $job_id = intval($_POST['id']);
+        execute_query_and_expect_one_row_to_be_affected('DELETE FROM jobs WHERE job_id = $1', array($job_id),
+            "Deleted job $job_id", "Could not delete job $job_id");
+    } else
+        notice('Invalid ID.');
+} else if ($action == 'manifest') {
+    execute_query_and_expect_one_row_to_be_affected('INSERT INTO jobs (job_type) VALUES (\'manifest\')', array(),
+        'Requested to regenerate the manifest.', 'Could not add a job');
+}
+
+if ($db) {
+
+?>
+<table>
+<thead>
+    <tr><td>ID</td><td>Type</td><td>Created At</td><td>Started At</td><td>PID</td><td>Attempts</td><td>Payload</td><td>Log</td><td>Actions</td>
+</thead>
+<tbody>
+<?php
+
+    function get_value_with_default($array, $key, $default) {
+        $value = $array[$key];
+        if (!$value)
+            $value = $default;
+        return $value;
+    }
+
+    # FIXME: Add a navigation bar for when there are more than 50 jobs.
+    $uncompleted_jobs = $db->query_and_fetch_all('SELECT * FROM jobs WHERE job_completed_at IS NULL LIMIT 50');
+    if ($uncompleted_jobs) {
+        foreach ($uncompleted_jobs as $job) {
+            $id = $job['job_id'];
+            $started_at = get_value_with_default($job, 'job_started_at', '');
+            $pid = get_value_with_default($job, 'job_started_by_pid', '');
+            echo <<< EOF
+    <tr>
+        <td>$id</td>
+        <td>{$job['job_type']}</td>
+        <td>{$job['job_created_at']}</td>
+        <td>$started_at</td><td>$pid</td>
+        <td>{$job['job_attempts']}</td>
+        <td><pre class="payload">{$job['job_payload']}</pre></td>
+        <td><pre>{$job['job_log']}</pre></td>
+        <td><form method='POST'><button type='submit' name='action' value='delete'>Delete</button><input type='hidden' name='id' value='$id'></form></td>
+    </tr>
+EOF;
+        }
+    }
+
+?></tbody>
+</table>
+
+<script>
+
+var payloadPres = document.querySelectorAll('.payload');
+for (var i = 0; i < payloadPres.length; ++i) {
+    var pre = payloadPres[i];
+    try {
+        pre.textContent = JSON.stringify(JSON.parse(pre.textContent), null, '  ');
+    } catch (exception) { } // Ignore exceptions.
+}
+
+</script>
+
+<section class="action-field">
+<h2>New job</h2>
+<form method='POST'><button type='submit' name='action' value='manifest'>Re-generate manifest</button></form>
+</section>
+
+<?php
+
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/platforms.php b/Websites/perf.webkit.org/public/admin/platforms.php
new file mode 100644 (file)
index 0000000..487ee9c
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+include('../include/admin-header.php');
+
+function merge_platforms($platform_to_merge, $destination_platform) {
+    global $db;
+
+    $db->begin_transaction();
+
+    // First, move all test runs to the test configurations in the destination for all test configurations that
+    // exist in both the original platform and the platform into which we're merging.
+    if (!$db->query_and_get_affected_rows('UPDATE test_runs SET run_config = destination.config_id
+        FROM test_configurations as merged, test_configurations as destination
+        WHERE merged.config_platform = $1 AND destination.config_platform = $2 AND run_config = merged.config_id
+            AND destination.config_metric = merged.config_metric', array($platform_to_merge, $destination_platform))) {
+        $db->rollback_transaction();
+        return notice("Failed to migrate test runs for $platform_to_merge that have test configurations in $destination_platform.");
+    }
+
+    // Then migrate test configurations that don't exist in the destination platform to the new platform
+    // so that test runs associated with those configurations are moved to the destination.
+    if ($db->query_and_get_affected_rows('UPDATE test_configurations SET config_platform = $2
+        WHERE config_platform = $1 AND config_metric NOT IN (SELECT config_metric FROM test_configurations WHERE config_platform = $2)',
+        array($platform_to_merge, $destination_platform)) === FALSE) {
+        $db->rollback_transaction();
+        return notice("Failed to migrate test configurations for $platform_to_merge.");
+    }
+
+    if ($db->query_and_fetch_all('SELECT * FROM test_runs, test_configurations WHERE run_config = config_id AND config_platform = $1', array($platform_to_merge))) {
+        // We should never reach here.
+        $db->rollback_transaction();
+        return notice('Failed to migrate all test runs.');
+    }
+
+    $db->query_and_get_affected_rows('DELETE FROM platforms WHERE platform_id = $1', array($platform_to_merge));
+    $db->commit_transaction();
+}
+
+if ($db) {
+    if ($action == 'update') {
+        if (update_field('platforms', 'platform', 'name')
+            || update_field('platforms', 'platform', 'hidden'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    } else if ($action == 'merge')
+        merge_platforms(intval($_POST['id']), intval($_POST['destination']));
+
+    $platforms = $db->fetch_table('platforms', 'platform_name');
+
+    function merge_list($platform_row) {
+        global $db;
+        global $platforms;
+
+        $id = $platform_row['platform_id'];
+        $content = <<< END
+<form method="POST"><input type="hidden" name="id" value="$id">
+<select name="destination">
+END;
+
+        foreach ($platforms as $platform) {
+            if ($platform['platform_id'] == $id)
+                continue;
+            $content .= <<< END
+<option value="{$platform['platform_id']}">{$platform['platform_name']}</option>
+END;
+        }
+
+        $content .= <<< END
+</select>
+<button type="submit" name="action" value="merge">Merge</button>
+</form>
+END;
+        return array($content);
+    }
+
+    $page = new AdministrativePage($db, 'platforms', 'platform', array(
+        'name' => array('editing_mode' => 'string'),
+        'hidden' => array('editing_mode' => 'boolean'),
+        'merge into' => array('custom' => function ($platform_row) { return merge_list($platform_row); }),
+    ));
+
+    $page->render_table('name');
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/regenerate-manifest.php b/Websites/perf.webkit.org/public/admin/regenerate-manifest.php
new file mode 100644 (file)
index 0000000..956d1ea
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+require_once('../include/admin-header.php');
+require_once('../include/manifest.php');
+
+if ($db) {
+    if (regenerate_manifest())
+        notice("Regenerated the manifest");
+}
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/reports.php b/Websites/perf.webkit.org/public/admin/reports.php
new file mode 100644 (file)
index 0000000..5baa46a
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+require('../include/admin-header.php');
+
+?><p><q>We ought never to allow ourselves to be persuaded of the truth of anything unless on the evidence of our reason.</q> - Ren&#xe9; Descartes</p><?php
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/repositories.php b/Websites/perf.webkit.org/public/admin/repositories.php
new file mode 100644 (file)
index 0000000..01844bd
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+include('../include/admin-header.php');
+
+if ($db) {
+    if ($action == 'update') {
+        if (update_field('repositories', 'repository', 'name')
+            || update_field('repositories', 'repository', 'url')
+            || update_field('repositories', 'repository', 'blame_url'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    }
+
+    $page = new AdministrativePage($db, 'repositories', 'repository', array(
+        'name' => array('editing_mode' => 'string'),
+        'url' => array('label' => 'Revision URL (At revision $1)', 'editing_mode' => 'url'),
+        'blame_url' => array('label' => 'Blame URL (From revision $1 to revision $2)', 'editing_mode' => 'url')
+    ));
+
+    $page->render_table('name');
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/reprocess-report.php b/Websites/perf.webkit.org/public/admin/reprocess-report.php
new file mode 100644 (file)
index 0000000..ea762ce
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+require_once('../include/json-header.php');
+require_once('../include/manifest.php');
+require_once('../include/report-processor.php');
+
+$db = new Database;
+if (!$db->connect())
+    exit_with_error('DatabaseConnectionFailure');
+
+$report_id = array_get($_POST, 'report');
+if (!$report_id)
+    $report_id = array_get($_GET, 'report');
+$report_id = intval($report_id);
+if (!$report_id)
+    exit_with_error('ReportIdNotSpecified');
+
+$report_row = $db->select_first_row('reports', 'report', array('id' => $report_id));
+if (!$report_row)
+    return exit_with_error('ReportNotFound', array('reportId', $report_id));
+
+$processor = new ReportProcessor($db);
+$processor->process(json_decode($report_row['report_content'], true), $report_id);
+
+$generator = new ManifestGenerator($db);
+if (!$generator->generate())
+    exit_with_error('FailedToGenerateManifest');
+else if (!$generator->store())
+    exit_with_error('FailedToStoreManifest');
+
+exit_with_success();
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/test-configurations.php b/Websites/perf.webkit.org/public/admin/test-configurations.php
new file mode 100644 (file)
index 0000000..ae3b978
--- /dev/null
@@ -0,0 +1,156 @@
+<?php
+
+require('../include/admin-header.php');
+require('../include/test-name-resolver.php');
+
+function add_run($metric_id, $platform_id, $type, $date, $mean) {
+    global $db;
+
+    $db->begin_transaction();
+
+    $config_id = $db->select_or_insert_row('test_configurations', 'config', array('metric' => $metric_id, 'platform' => $platform_id, 'type' => $type));
+    if (!$config_id) {
+        $db->rollback_transaction();
+        return notice("Failed to add the configuration for metric $metric_id and platform $platform_id");
+    }
+
+    $build_id = $db->insert_row('builds', 'build', array('number' => 0, 'time' => $date));
+    if (!$build_id) {
+        $db->rollback_transaction();
+        return notice("Failed to add a build");
+    }
+
+    // FIXME: Should we insert run_iterations?
+    $run_id = $db->insert_row('test_runs', 'run', array('config' => $config_id, 'build' => $build_id, 'iteration_count_cache' => 1, 'mean_cache' => $mean));
+    if (!$run_id) {
+        $db->rollback_transaction();
+        return notice("Failed to add a run");
+    }
+
+    $db->commit_transaction();
+    notice("Added a baseline test run.");
+}
+
+function delete_run($run_id, $build_id) {
+    global $db;
+
+    $db->begin_transaction();
+
+    $build_counts = $db->query_and_fetch_all('SELECT COUNT(*) FROM test_runs WHERE run_build = $1', array($build_id));
+    if (!$build_counts) {
+        $db->rollback_transaction();
+        return notice("Failed to obtain the number of runs for the build $build_id");
+    }
+
+    if ($build_counts[0]['count'] != 1) {
+        $db->rollback_transaction();
+        return notice("The build $build_id doesn't have exactly one run. Either the build id is wrong or it's not a synthetic build.");
+    }
+
+    $removed_runs = $db->query_and_fetch_all('DELETE FROM test_runs WHERE run_id = $1 RETURNING run_build', array($run_id));
+    if (!$removed_runs || count($removed_runs) != 1) {
+        $db->rollback_transaction();
+        return notice("Failed to delete the run $run_id.");
+    }
+    $associated_build = $removed_runs[0]['run_build'];
+    if ($associated_build != $build_id) {
+        $db->rollback_transaction();
+        return notice("Failed to delete the run $run_id because it was associated with the build $associated_build instead of the build $build_id");
+    }
+
+    $removed_builds = $db->query_and_get_affected_rows('DELETE FROM builds WHERE build_id = $1', array($build_id));
+    if (!$removed_runs || count($removed_runs) != 1) {
+        $db->rollback_transaction();
+        return notice("Failed to delete the build $build_id.");
+    }
+
+    $db->commit_transaction();
+}
+
+if ($db) {
+    date_default_timezone_set('Etc/UTC');
+
+    if ($action == 'add-run' && array_get($_POST, 'metric') && array_get($_POST, 'platform')
+        && array_get($_POST, 'config-type') && array_get($_POST, 'date') && array_get($_POST, 'mean'))
+        add_run(intval($_POST['metric']), intval($_POST['platform']), $_POST['config-type'], $_POST['date'], floatval($_POST['mean']));
+    else if ($action == 'delete-run' && array_get($_POST, 'run') && array_get($_POST, 'build'))
+        delete_run(intval($_POST['run']), intval($_POST['build']));
+    else if ($action)
+        notice("Invalid arguments");
+
+    $metric_id = intval(array_get($_GET, 'metric'));
+
+    $test_name_resolver = new TestNameResolver($db);
+    $full_metric_name = $test_name_resolver->full_name_for_metric($metric_id);
+    echo "<h2>$full_metric_name</h2>";
+
+    $page = new AdministrativePage($db, 'platforms', 'platform', array(
+        'name' => array('label' => 'Platform Name'),
+        'Configurations' => array('subcolumns'=> array('ID', 'Type'), 'custom' => function ($platform_row) {
+            return generate_rows_for_configurations($platform_row['platform_id']);
+        }),
+        'Baseline Test Runs' => array('subcolumns'=> array('Run ID', 'Build ID', 'Time', 'Mean', 'Actions'), 'custom' => function ($platform_row) {
+            return generate_rows_for_test_runs($platform_row['platform_id'], 'baseline');
+        }),
+        'Target Test Runs' => array('subcolumns'=> array('Run ID', 'Build ID', 'Time', 'Mean', 'Actions'), 'custom' => function ($platform_row) {
+            return generate_rows_for_test_runs($platform_row['platform_id'], 'target');
+        }),
+    ));
+
+    function generate_rows_for_configurations($platform_id) {
+        global $test_name_resolver;
+        global $metric_id;
+        $rows = array();
+        if ($configurations = $test_name_resolver->configurations_for_metric_and_platform($metric_id, $platform_id)) {
+            foreach ($configurations as $config)
+                array_push($rows, array($config['config_id'], $config['config_type']));
+        }
+        return $rows;
+    }
+
+    function generate_rows_for_test_runs($platform_id, $config_type) {
+        global $metric_id;
+        global $db;
+
+        $baseline_runs = $db->query_and_fetch_all('SELECT * FROM test_runs, test_configurations, builds
+            WHERE run_config = config_id AND run_build = build_id
+            AND config_metric = $1 AND config_platform = $2 AND config_type = $3
+            ORDER BY build_time DESC LIMIT 6', array($metric_id, $platform_id, $config_type));
+
+        $rows = array();
+        if ($baseline_runs) {
+            foreach ($baseline_runs as $run) {
+                $deletion_form = <<< END
+<form method="POST">
+<input type="hidden" name="run" value="{$run['run_id']}">
+<input type="hidden" name="build" value="{$run['run_build']}">
+<button type="submit" name="action" value="delete-run">Delete</button>
+</form>
+END;
+                array_push($rows, array($run['run_id'], $run['build_id'], $run['build_time'], $run['run_mean_cache'], $deletion_form));
+            }
+
+        }
+
+        $form = <<< END
+<form method="POST">
+<input type="hidden" name="metric" value="$metric_id">
+<input type="hidden" name="platform" value="$platform_id">
+<input type="hidden" name="config-type" value="$config_type">
+<label>Date: <input type="text" name="date"></label>
+<label>Mean: <input type="text" name="mean"></label>
+<button type="submit" name="action" value="add-run">Add</button>
+</form>
+END;
+
+        array_push($rows, $form);
+
+        return $rows;
+    }
+
+    $page->render_table('name');
+}
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/admin/tests.php b/Websites/perf.webkit.org/public/admin/tests.php
new file mode 100644 (file)
index 0000000..31f9d5e
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+
+require_once('../include/admin-header.php');
+require_once('../include/test-name-resolver.php');
+
+if ($action == 'dashboard') {
+    if (array_key_exists('metric_id', $_POST)) {
+        $metric_id = intval($_POST['metric_id']);
+        $config_ids = array();
+        $succeeded = TRUE;
+        if (!$db->query_and_get_affected_rows("UPDATE test_configurations SET config_is_in_dashboard = false WHERE config_metric = $1", array($metric_id))) {
+            $succeeded = FALSE;
+            notice("Failed to remove some configurations from the dashboard.");
+        }
+
+        else if (array_key_exists('metric_platforms', $_POST)) {
+            foreach ($_POST['metric_platforms'] as $platform_id) {
+                if (!$db->query_and_get_affected_rows("UPDATE test_configurations SET config_is_in_dashboard = true
+                    WHERE config_metric = $1 AND config_platform = $2", array($metric_id, $platform_id))) {
+                    $succeeded = FALSE;
+                    notice("Failed to add configurations for metric $metric_id and platform $platform_id to the dashboard.");
+                }
+            }
+        }
+
+        if ($succeeded) {
+            notice("Updated the dashboard.");
+            regenerate_manifest();
+        }
+    } else
+        notice('Invalid parameters');
+} else if ($action == 'update') {
+    if (!update_field('tests', 'test', 'url'))
+        notice('Invalid parameters');
+} else if ($action == 'add') {
+    if (array_key_exists('test_id', $_POST) && array_key_exists('metric_name', $_POST)) {
+        $id = intval($_POST['test_id']);
+        $aggregator = intval($_POST['metric_aggregator']);
+        if (!$aggregator)
+            notice('Invalid aggregator. You must specify one.');
+        else {
+            $metric_id = $db->insert_row('test_metrics', 'metric',
+                array('test' => $id, 'name' => $_POST['metric_name'], 'aggregator' => $aggregator));
+            if (!$metric_id)
+                notice("Could not insert the new metric for test $id");
+            else {
+                add_job('aggregate', '{"metricIds": [ ' . $metric_id . ']}');
+                notice("Inserted the metric for test $id");
+            }
+        }
+    } else if (array_key_exists('metric_id', $_POST))
+        regenerate_manifest();
+    else
+        notice('Invalid parameters');
+}
+
+if ($db) {
+    $aggregators = array();
+    if ($aggregators_table = $db->fetch_table('aggregators')) {
+        foreach ($aggregators_table as $aggregator_row)
+            $aggregators[$aggregator_row['aggregator_id']] = $aggregator_row['aggregator_name'];
+    }
+
+    $test_name_resolver = new TestNameResolver($db);
+    if ($test_name_resolver->tests()) {
+        $name_to_platform = array();
+
+        foreach ($db->fetch_table('platforms') as $platform)
+            $name_to_platform[$platform['platform_name']] = $platform;
+
+        $platform_names = array_keys($name_to_platform);
+        asort($platform_names);
+
+        $odd = false;
+        $selected_parent_full_name = trim(array_get($_SERVER, 'PATH_INFO', ''), '/');
+        $selected_parent = $test_name_resolver->test_id_for_full_name($selected_parent_full_name);
+        if ($selected_parent)
+            echo '<h2>' . htmlspecialchars($selected_parent_full_name) . '</h2>';
+
+?>
+<table>
+<thead>
+    <tr><td>Test ID</td><td>Full Name</td><td>Parent ID</td><td>URL</td>
+        <td>Metric ID</td><td>Metric Name</td><td>Aggregator</td><td>Dashboard</td>
+</thead>
+<tbody>
+<?php
+
+        foreach ($test_name_resolver->tests() as $test) {
+            if ($test['test_parent'] != $selected_parent['test_id'])
+                continue;
+
+            $test_id = $test['test_id'];
+            $test_metrics = $test_name_resolver->metrics_for_test_id($test_id);
+            $row_count = count($test_metrics);
+            $child_metrics = $test_name_resolver->child_metrics_for_test_id($test_id);
+            $linked_test_name = htmlspecialchars($test['full_name']);
+            if ($child_metrics) {
+                $row_count++;
+                $linked_test_name = '<a href="/admin/tests/' . htmlspecialchars($test['full_name']) . '">' . $linked_test_name . '</a>';
+            }
+
+            $tbody_class = $odd ? 'odd' : 'even';
+            $odd = !$odd;
+
+            $test_url = htmlspecialchars($test['test_url']);
+
+            echo <<<EOF
+    <tbody class="$tbody_class">
+    <tr>
+        <td rowspan="$row_count">$test_id</td>
+        <td rowspan="$row_count">$linked_test_name</td>
+        <td rowspan="$row_count">{$test['test_parent']}</td>
+        <td rowspan="$row_count">
+        <form method="POST"><input type="hidden" name="id" value="$test_id">
+        <input type="hidden" name="action" value="update">
+        <input type="url" name="url" value="$test_url" size="80"></form></td>
+EOF;
+
+            if ($test_metrics) {
+                $has_tr = true;
+                foreach ($test_metrics as $metric) {
+                    $aggregator_name = array_get($aggregators, $metric['metric_aggregator'], '');
+                    if ($aggregator_name) {
+                        $aggregator_action = '<form method="POST"><input type="hidden" name="metric_id" value="'. $metric['metric_id']
+                            . '"><button type="submit" name="action" value="add">Regenerate</button></form>';
+                    } else
+                        $aggregator_action = '';
+
+                    if (!$has_tr)
+                        echo <<<EOF
+
+    <tr>
+EOF;
+                    $has_tr = false;
+
+                    $metric_id = $metric['metric_id'];
+                    echo <<<EOF
+        <td><a href="/admin/test-configurations?metric=$metric_id">$metric_id</a></td>
+        <td>{$metric['metric_name']}</td>
+        <td>$aggregator_name $aggregator_action</td>
+        <td><form method="POST"><input type="hidden" name="metric_id" value="$metric_id">
+EOF;
+
+                    foreach ($platform_names as $platform_name) {
+                        $platform_name = htmlspecialchars($platform_name);
+                        $platform = $name_to_platform[$platform_name];
+                        $configurations = $test_name_resolver->configurations_for_metric_and_platform($metric_id, $platform['platform_id']);
+                        if (!$configurations)
+                            continue;
+                        echo "<label><input type=\"checkbox\" name=\"metric_platforms[]\" value=\"{$platform['platform_id']}\"";
+                        if ($db->is_true($configurations[0]['config_is_in_dashboard']))
+                            echo ' checked';
+                        else if ($db->is_true($platform['platform_hidden']))
+                            echo 'disabled';
+                        echo ">$platform_name</label>";
+                    }
+
+                    echo <<<EOF
+        <button type="submit" name="action" value="dashboard">Save</button></form>
+        </td>
+    </tr>
+EOF;
+                }
+            }
+
+            if ($child_metrics) {
+                echo <<<EOF
+        <td colspan="5"><form method="POST">
+        <input type="hidden" name="test_id" value="$test_id">
+        <label>Name<select name="metric_name">
+EOF;
+
+                foreach ($child_metrics as $metric_name) {
+                    $metric_name = htmlspecialchars($metric_name);
+                    echo "
+            <option>$metric_name</option>";
+                }
+
+                echo <<<EOF
+        </select></label>
+        <label>Aggregator
+        <select name="metric_aggregator">
+            <option value="">-</option>
+EOF;
+            foreach ($aggregators as $id => $name) {
+                $name = htmlspecialchars($name);
+                echo "
+            <option value=\"$id\">$name</option>";
+            }
+            echo <<<EOF
+        </select></label>
+        <button type="submit" name="action" value="add">Add</button></form></td>
+    </tr>
+EOF;
+            }
+            echo <<<EOF
+    </tbody>
+EOF;
+        }
+
+        ?></tbody>
+</table>
+
+<?php
+
+    }
+
+}
+
+include('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/api/report.php b/Websites/perf.webkit.org/public/api/report.php
new file mode 100644 (file)
index 0000000..6410676
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+require_once('../include/json-header.php');
+require_once('../include/manifest.php');
+require_once('../include/report-processor.php');
+
+function main($post_data) {
+    set_exit_detail('failureStored', false);
+
+    $db = new Database;
+    if (!$db->connect())
+        exit_with_error('DatabaseConnectionFailure');
+
+    // Convert all floating points to strings to avoid parsing them in PHP.
+    // FIXME: Do this conversion in the submission scripts themselves.
+    $parsed_json = json_decode(preg_replace('/(?<=[\s,\[])(\d+(\.\d+)?)(\s*[,\]])/', '"$1"$3', $post_data), true);
+    if (!$parsed_json)
+        exit_with_error('FailedToParseJSON');
+
+    set_exit_detail('processedRuns', 0);
+    foreach ($parsed_json as $i => $report) {
+        $processor = new ReportProcessor($db);
+        $processor->process($report);
+        set_exit_detail('processedRuns', $i + 1);
+    }
+
+    $generator = new ManifestGenerator($db);
+    if (!$generator->generate())
+        exit_with_error('FailedToGenerateManifest');
+    else if (!$generator->store())
+        exit_with_error('FailedToStoreManifest');
+
+    exit_with_success();
+}
+
+main($HTTP_RAW_POST_DATA);
+
+?>
diff --git a/Websites/perf.webkit.org/public/api/runs.php b/Websites/perf.webkit.org/public/api/runs.php
new file mode 100644 (file)
index 0000000..76223c4
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+require('../include/json-header.php');
+
+$paths = array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array();
+
+if (count($paths) != 1)
+    exit_with_error('InvalidRequest');
+
+$parts = explode('-', $paths[0]);
+if (count($parts) != 2)
+    exit_with_error('InvalidRequest');
+
+$db = new Database;
+if (!$db->connect())
+    exit_with_error('DatabaseConnectionFailure');
+
+$platform_id = intval($parts[0]);
+$metric_id = intval($parts[1]);
+$config_rows = $db->query_and_fetch_all('SELECT config_id, config_type, config_platform, config_metric
+    FROM test_configurations WHERE config_metric = $1 AND config_platform = $2', array($metric_id, $platform_id));
+if (!$config_rows)
+    exit_with_error('ConfigurationNotFound');
+
+$repository_id_to_name = array();
+if ($repository_table = $db->fetch_table('repositories')) {
+    foreach ($repository_table as $repository)
+        $repository_id_to_name[$repository['repository_id']] = $repository['repository_name'];
+}
+
+function fetch_runs_for_config($db, $config) {
+    $raw_runs = $db->query_and_fetch_all('
+    SELECT test_runs.*, builds.*, array_agg((revision_repository, revision_value, revision_time)) AS revisions
+        FROM builds LEFT OUTER JOIN build_revisions ON revision_build = build_id, test_runs
+        WHERE run_build = build_id AND run_config = $1
+        GROUP BY build_id, build_builder, build_number, build_time, build_latest_revision,
+            run_id, run_config, run_build, run_iteration_count_cache,
+            run_mean_cache, run_sum_cache, run_square_sum_cache
+        ORDER BY build_latest_revision, build_time', array($config['config_id']));
+
+    $formatted_runs = array();
+    if (!$raw_runs)
+        return $formatted_runs;
+
+    foreach ($raw_runs as $run)
+        array_push($formatted_runs, format_run($run));
+
+    return $formatted_runs;
+}
+
+date_default_timezone_set('UTC');
+function parse_revisions_array($postgres_array) {
+    global $repository_id_to_name;
+
+    // e.g. {"(WebKit,131456,\"2012-10-16 14:53:00\")","(Chromium,162004,)"}
+    $outer_array = json_decode('[' . trim($postgres_array, '{}') . ']');
+    $revisions = array();
+    foreach ($outer_array as $item) {
+        $name_and_revision = explode(',', trim($item, '()'));
+        if (!$name_and_revision[0])
+            continue;
+        $time = strtotime(trim($name_and_revision[2], '"')) * 1000;
+        $revisions[$repository_id_to_name[trim($name_and_revision[0], '"')]] = array(trim($name_and_revision[1], '"'), $time);
+    }
+    return $revisions;
+}
+
+function format_run($run) {
+    return array(
+        'mean' => floatval($run['run_mean_cache']),
+        'iterationCount' => intval($run['run_iteration_count_cache']),
+        'sum' => floatval($run['run_sum_cache']),
+        'squareSum' => floatval($run['run_square_sum_cache']),
+        'revisions' => parse_revisions_array($run['revisions']),
+        'buildTime' => strtotime($run['build_time']) * 1000,
+        'buildNumber' => intval($run['build_number']),
+        'builder' => $run['build_builder']);
+}
+
+$results = array();
+foreach ($config_rows as $config) {
+    if ($runs = fetch_runs_for_config($db, $config))
+        $results[$config['config_type']] = $runs;
+}
+
+exit_with_success($results);
+
+?>
diff --git a/Websites/perf.webkit.org/public/common.css b/Websites/perf.webkit.org/public/common.css
new file mode 100644 (file)
index 0000000..dcb6a24
--- /dev/null
@@ -0,0 +1,65 @@
+
+html, body {
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    background-repeat: repeat-x;
+    font-family: sans-serif;
+    padding: 10px;
+}
+
+#title {
+    background-image: linear-gradient(bottom, rgb(240,240,240) 31%, rgb(255,255,255) 90%);
+    background-image: -o-linear-gradient(bottom, rgb(240,240,240) 31%, rgb(255,255,255) 90%);
+    background-image: -moz-linear-gradient(bottom, rgb(240,240,240) 31%, rgb(255,255,255) 90%);
+    background-image: -webkit-linear-gradient(bottom, rgb(240,240,240) 31%, rgb(255,255,255) 90%);
+    background-image: -ms-linear-gradient(bottom, rgb(240,240,240) 31%, rgb(255,255,255) 90%);
+    -moz-box-shadow:    1px 1px 3px 1px #ccc;
+    -webkit-box-shadow: 1px 1px 3px 1px #ccc;
+    box-shadow:         1px 1px 3px 1px #ccc;
+    padding: 5px 10px;
+    margin: 0 0 20px 0;
+    border-radius: 5px;
+    position: relative;
+}
+
+#title h1 {
+    font-weight: normal;
+    text-shadow: #bbb 1px 1px 2px;
+    margin: 0;
+    padding: 0;
+    font-size: 2em;
+}
+#title li, #title ul {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+
+#title li {
+    display: inline;
+}
+
+#title li:after {
+    content: ' | ';
+}
+
+#title li:last-child:after {
+    content: '';
+}
+
+#title ul {
+    position: absolute;
+    vertical-align: middle;
+    top: 5px;
+    right: 20px;
+    padding-top: 0.6em;
+}
+
+a {
+    text-decoration: none;
+    color: #000;
+    text-shadow: #bbb 1px 1px 2px;
+}
diff --git a/Websites/perf.webkit.org/public/include/admin-footer.php b/Websites/perf.webkit.org/public/include/admin-footer.php
new file mode 100644 (file)
index 0000000..aa5a7c3
--- /dev/null
@@ -0,0 +1,4 @@
+</div>
+
+</body>
+</html>
diff --git a/Websites/perf.webkit.org/public/include/admin-header.php b/Websites/perf.webkit.org/public/include/admin-header.php
new file mode 100644 (file)
index 0000000..882d664
--- /dev/null
@@ -0,0 +1,326 @@
+<?php
+
+require_once('db.php');
+require_once('manifest.php');
+
+?><!DOCTYPE html>
+<html>
+<head>
+<title>WebKit Perf Monitor</title>
+<link rel="stylesheet" href="/common.css">
+<link rel="stylesheet" href="/admin/admin.css">
+</head>
+<body>
+<header id="title">
+<h1><a href="/">WebKit Perf Monitor</a></h1>
+<ul>
+    <li><a href="/admin/platforms">Platforms</a></li>
+    <li><a href="/admin/tests">Tests</a></li>
+    <li><a href="/admin/jobs">Jobs</a></li>
+    <li><a href="/admin/aggregators">Aggregators</a></li>
+    <li><a href="/admin/builders">Builders</a></li>
+    <li><a href="/admin/repositories">Repositories</a></li>
+    <li><a href="/admin/bug-trackers">Bug Trackers</a></li>
+</ul>
+</header>
+
+<div id="mainContents">
+<?php
+
+function notice($message) {
+    echo "<p class='notice'>$message</p>";
+}
+
+$db = new Database;
+if (!$db->connect()) {
+    notice('Failed to connect to the database');
+    $db = NULL;
+} else
+    $action = array_key_exists('action', $_POST) ? $_POST['action'] : NULL;
+
+function execute_query_and_expect_one_row_to_be_affected($query, $params, $success_message, $failure_message) {
+    global $db;
+
+    foreach ($params as &$param) {
+        if ($param == '')
+            $param = NULL;
+    }
+
+    $affected_rows = $db->query_and_get_affected_rows($query, $params);
+    if ($affected_rows) {
+        assert('$affected_rows == 1');
+        notice($success_message);
+        return true;
+    }
+
+    notice($failure_message);
+    return false;
+}
+
+function update_field($table, $prefix, $field_name) {
+    global $db;
+
+    if (!array_key_exists('id', $_POST) || (array_get($_POST, 'updated-column') != $field_name && !array_key_exists($field_name, $_POST)))
+        return FALSE;
+
+    $id = intval($_POST['id']);
+    $prefixed_field_name = $prefix . '_' . $field_name;
+    $id_field_name = $prefix . '_id';
+
+    execute_query_and_expect_one_row_to_be_affected("UPDATE $table SET $prefixed_field_name = \$2 WHERE $id_field_name = \$1",
+        array($id, array_get($_POST, $field_name)),
+        "Updated the $prefix $id",
+        "Could not update $prefix $id");
+
+    return TRUE;
+}
+
+function regenerate_manifest() {
+    global $db;
+
+    $generator = new ManifestGenerator($db);
+    if (!$generator->generate()) {
+        notice("Failed to generate the manifest (before trying to write into the filesystem).");
+        return FALSE;
+    }
+
+    if (!$generator->store()) {
+        notice("Failed to save the generated manifest into the filesystem");
+        return FALSE;
+    }
+
+    return TRUE;
+}
+
+function add_job($type, $payload = null) {
+    global $db;
+
+    if ($db->insert_row('jobs', 'job', array('type' => $type, 'payload' => $payload)))
+        notice("Added a job of type $type");
+    else
+        notice("Failed to add job of type $type");
+}
+
+class AdministrativePage {
+    private $table;
+    private $prefix;
+    private $column_to_be_ordered_by;
+    private $column_info;
+
+    function __construct($db, $table, $prefix, $column_info) {
+        $this->db = $db;
+        $this->table = $table;
+        $this->prefix = $prefix;
+        $this->column_info = $column_info;
+    }
+
+    private function name_to_titlecase($name) {
+        return ucwords(str_replace('_', ' ', $name));
+    }
+
+    private function column_label($name) {
+        return array_get($this->column_info[$name], 'label', $this->name_to_titlecase($name));
+    }
+
+    private function render_form_control_for_column($editing_mode, $name, $value = '', $show_update_button_if_needed = FALSE) {
+        $show_update_button = FALSE;
+        switch ($editing_mode) {
+        case 'text':
+            echo <<< END
+<textarea name="$name" rows="7" cols="50">$value</textarea><br>
+END;
+            $show_update_button = $show_update_button_if_needed;
+            break;
+        case 'boolean':
+            $checkedness = $this->db->is_true($value) ? ' checked' : '';
+            echo <<< END
+<input type="checkbox" name="$name"$checkedness>
+END;
+            $show_update_button = $show_update_button_if_needed;
+            break;
+        case 'url':
+            echo <<< END
+<input type="text" name="$name" value="$value" size="70">
+END;
+            break;
+        default:
+            assert($editing_mode == 'string');
+            echo <<< END
+<input type="text" name="$name" value="$value">
+END;
+        }
+
+        if ($show_update_button) {
+            echo <<< END
+
+<button type="submit" name="action" value="update">Update</button>
+END;
+        }
+    }
+
+    function render_table($column_to_be_ordered_by) {
+        $column_names = array_keys($this->column_info);
+        $column_to_subcolumn_names = array();
+        foreach ($column_names as $name) {
+            if (array_get($this->column_info[$name], 'pre_insertion'))
+                continue;
+            $subcolumns = array_get($this->column_info[$name], 'subcolumns', array());
+            if (!$subcolumns || !array_get($this->column_info[$name], 'custom'))
+                continue;
+            $column_to_subcolumn_names[$name] = $subcolumns;
+        }
+
+        $rowspan_if_needed = $column_to_subcolumn_names ? ' rowspan="2"' : '';
+
+        $headers = "<tr><td$rowspan_if_needed>ID</td>";
+        foreach ($column_names as $name) {
+            if (array_get($this->column_info[$name], 'pre_insertion'))
+                continue;
+            $label = htmlspecialchars($this->column_label($name));
+            if (array_get($column_to_subcolumn_names, $name)) {
+                $count = count($column_to_subcolumn_names[$name]);
+                $headers .= "<td colspan=\"$count\">$label</td>";
+            } else
+                $headers .= "<td$rowspan_if_needed>$label</td>";
+        }
+        $headers .= "</tr>\n";
+
+        if ($column_to_subcolumn_names) {
+            $headers .= '<tr>';
+            foreach ($column_names as $name) {
+                $subcolumn_names = array_get($column_to_subcolumn_names, $name);
+                if (!$subcolumn_names)
+                    continue;
+                foreach ($subcolumn_names as $label)
+                    $headers .= '<td>' . htmlspecialchars($label) . '</td>';
+            }
+            $headers .= "</tr>\n";
+        }
+
+        echo <<< END
+<table>
+<thead>
+$headers
+</thead>
+<tbody>
+
+END;
+
+        assert(ctype_alnum_underscore($column_to_be_ordered_by));
+        $rows = $this->db->fetch_table($this->table, $this->prefix . '_' . $column_to_be_ordered_by);
+        if ($rows) {
+            foreach ($rows as $row) {
+                $id = intval($row[$this->prefix . '_id']);
+
+                $custom_cells_list = array();
+                $maximum_rows = 1;
+                foreach ($column_names as $name) {
+                    if (array_get($this->column_info[$name], 'pre_insertion'))
+                        continue;
+
+                    if ($custom = array_get($this->column_info[$name], 'custom')) {
+                        $custom_cells_list[$name] = $custom($row);
+                        $maximum_rows = max($maximum_rows, count($custom_cells_list[$name]));
+                    }
+                }
+
+                $rowspan_if_needed = $maximum_rows > 1 ? ' rowspan="' . $maximum_rows . '"' : '';
+
+                echo "<tr>\n<td$rowspan_if_needed>$id</td>\n";
+                foreach ($column_names as $name) {
+                    if (array_get($this->column_info[$name], 'pre_insertion'))
+                        continue;
+
+                    if (array_key_exists($name, $custom_cells_list)) {
+                        $this->render_custom_cells(array_get($column_to_subcolumn_names, $name), $custom_cells_list[$name], 0);
+                        continue;
+                    }
+
+                    $value = htmlspecialchars($row[$this->prefix . '_' . $name], ENT_QUOTES);
+                    $editing_mode = array_get($this->column_info[$name], 'editing_mode');
+                    if (!$editing_mode) {
+                        echo "<td$rowspan_if_needed>$value</td>\n";
+                        continue;
+                    }
+
+                    echo <<< END
+<td$rowspan_if_needed>
+<form method="POST">
+<input type="hidden" name="id" value="$id">
+<input type="hidden" name="action" value="update">
+<input type="hidden" name="updated-column" value="$name">
+
+END;
+                    $this->render_form_control_for_column($editing_mode, $name, $value, TRUE);
+                    echo "</form></td>\n";
+
+                }
+                echo "</tr>\n";
+
+                for ($row = 1; $row < $maximum_rows; $row++) {
+                    echo "<tr>\n";
+                    foreach ($column_names as $name) {
+                        if (array_key_exists($name, $custom_cells_list))
+                            $this->render_custom_cells(array_get($column_to_subcolumn_names, $name), $custom_cells_list[$name], $row);
+                    }
+                    echo "</tr>\n";
+                }
+            }
+        }
+        echo <<< END
+</tbody>
+</table>
+END;
+    }
+
+    function render_custom_cells($subcolum_names, $rows, $row_index) {
+        $cells = array_get($rows, $row_index, array());
+        if (!is_array($cells))
+            $cells = array($cells);
+
+        if ($subcolum_names && count($cells) <= 1) {
+            $colspan = count($subcolum_names);
+            $content = $cells ? $cells[0] : '';
+            echo "<td colspan=\"$colspan\">$content</td>\n";
+            return;
+        }
+
+        for ($i = 0; $i < ($subcolum_names ? count($subcolum_names) : 1); $i++)
+            echo '<td>' . array_get($cells, $i, '') . '</td>';
+        echo "\n";
+    }
+
+    function render_form_to_add($title = NULL) {
+
+        if (!$title) # Can't use the table name since it needs to be singular.
+            $title = 'New ' . $this->name_to_titlecase($this->prefix);
+
+echo <<< END
+<section class="action-field">
+<h2>$title</h2>
+<form method="POST">
+
+END;
+        foreach (array_keys($this->column_info) as $name) {
+            $editing_mode = array_get($this->column_info[$name], 'editing_mode');
+            if (array_get($this->column_info[$name], 'custom') || !$editing_mode)
+                continue;
+
+            $label = htmlspecialchars($this->column_label($name));
+            echo "<label>$label<br>\n";
+            $this->render_form_control_for_column($editing_mode, $name);
+            echo "</label><br>\n";
+        }
+
+echo <<< END
+
+<button type="submit" name="action" value="add">Add</button>
+</form>
+</section>
+END;
+
+    }
+
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/include/db.php b/Websites/perf.webkit.org/public/include/db.php
new file mode 100644 (file)
index 0000000..adce27d
--- /dev/null
@@ -0,0 +1,201 @@
+<?php
+
+function ends_with($str, $key) {
+    return strrpos($str, $key) == strlen($str) - strlen($key);
+}
+
+function ctype_alnum_underscore($str) {
+    return ctype_alnum(str_replace('_', '', $str));
+}
+
+function &array_ensure_item_has_array(&$array, $key) {
+    if (!array_key_exists($key, $array))
+        $array[$key] = array();
+    return $array[$key];
+}
+
+function array_get($array, $key, $default = NULL) {
+    if (!array_key_exists($key, $array))
+        return $default;
+    return $array[$key];
+}
+
+function array_set_default(&$array, $key, $default) {
+    if (!array_key_exists($key, $array))
+        $array[$key] = $default;
+}
+
+$_config = NULL;
+
+function config($key) {
+    global $_config;
+    if (!$_config)
+        $_config = json_decode(file_get_contents(dirname(__FILE__) . '/../../config.json'), true);
+    return $_config[$key];
+}
+
+if (config('debug')) {
+    error_reporting(E_ALL | E_STRICT);
+    ini_set('display_errors', 'On');
+} else
+    error_reporting(E_ERROR);
+
+class Database
+{
+    private $connection = false;
+
+    function __destruct() {
+        if ($this->connection)
+            pg_close($this->connection);
+        $this->connection = false;
+    }
+
+    function is_true($value) {
+        return $value == 't';
+    }
+
+    function connect() {
+        $databaseConfig = config('database');
+        $this->connection = pg_connect('host=' . $databaseConfig['host'] . ' port=' . $databaseConfig['port']
+            . ' dbname=' . $databaseConfig['name'] . ' user=' . $databaseConfig['username'] . ' password=' . $databaseConfig['password']);
+        return $this->connection ? true : false;
+    }
+
+    private function prefixed_column_names($columns, $prefix = NULL) {
+        if (!$prefix)
+            return join(', ', $columns);
+        return $prefix . '_' . join(', ' . $prefix . '_', $columns);
+    }
+
+    private function prefixed_name($column, $prefix = NULL) {
+        return $prefix ? $prefix . '_' . $column : $column;
+    }
+
+    private function prepare_params($params, &$placeholders, &$values) {
+        $column_names = array_keys($params);
+
+        $i = count($values) + 1;
+        foreach ($column_names as $name) {
+            assert(ctype_alnum_underscore($name));
+            array_push($placeholders, '$' . $i);
+            array_push($values, $params[$name]);
+            $i++;
+        }
+
+        return $column_names;
+    }
+
+    function insert_row($table, $prefix, $params, $returning = 'id') {
+        $placeholders = array();
+        $values = array();
+        $column_names = $this->prepare_params($params, $placeholders, $values);
+
+        assert(!$prefix || ctype_alnum_underscore($prefix));
+        $column_names = $this->prefixed_column_names($column_names, $prefix);
+        $placeholders = join(', ', $placeholders);
+
+        if ($returning) {
+            $returning_column_name = $this->prefixed_name($returning, $prefix);
+            $rows = $this->query_and_fetch_all("INSERT INTO $table ($column_names) VALUES ($placeholders) RETURNING $returning_column_name", $values);
+            return $rows ? $rows[0][$returning_column_name] : NULL;
+        }
+
+        return $this->query_and_get_affected_rows("INSERT INTO $table ($column_names) VALUES ($placeholders)", $values) == 1;
+    }
+
+    function select_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
+        $values = array();
+
+        $select_placeholders = array();
+        $select_column_names = $this->prepare_params($select_params, $select_placeholders, $values);
+        $select_values = array_slice($values, 0);
+
+        if ($insert_params === NULL)
+            $insert_params = $select_params;
+        $insert_placeholders = array();
+        $insert_column_names = $this->prepare_params($insert_params, $insert_placeholders, $values);
+
+        assert(!!$returning);
+        assert(!$prefix || ctype_alnum_underscore($prefix));
+        $returning_column_name = $returning == '*' ? '*' : $this->prefixed_name($returning, $prefix);
+        $select_column_names = $this->prefixed_column_names($select_column_names, $prefix);
+        $select_placeholders = join(', ', $select_placeholders);
+        $query = "SELECT $returning_column_name FROM $table WHERE ($select_column_names) = ($select_placeholders)";
+
+        $insert_column_names = $this->prefixed_column_names($insert_column_names, $prefix);
+        $insert_placeholders = join(', ', $insert_placeholders);
+        $rows = $this->query_and_fetch_all("INSERT INTO $table ($insert_column_names) SELECT $insert_placeholders WHERE NOT EXISTS
+            ($query) RETURNING $returning_column_name", $values);
+        if (!$rows)
+            $rows = $this->query_and_fetch_all($query, $select_values);
+
+        return $rows ? ($returning == '*' ? $rows[0] : $rows[0][$returning_column_name]) : NULL;
+    }
+
+    function select_first_row($table, $prefix, $params, $order_by = NULL) {
+        $placeholders = array();
+        $values = array();
+        $column_names = $this->prefixed_column_names($this->prepare_params($params, $placeholders, $values), $prefix);
+        $placeholders = join(', ', $placeholders);
+        $query = "SELECT * FROM $table WHERE ($column_names) = ($placeholders)";
+        if ($order_by) {
+            assert(!ctype_alnum_underscore($order_by));
+            $query .= ' ORDER BY ' . $this->prefixed_name($order_by, $prefix);
+        }
+        $rows = $this->query_and_fetch_all($query . ' LIMIT 1', $values);
+
+        return $rows ? $rows[0] : NULL;
+    }
+
+    function query_and_get_affected_rows($query, $params = array()) {
+        if (!$this->connection)
+            return FALSE;
+        $result = pg_query_params($this->connection, $query, $params);
+        if (!$result)
+            return FALSE;
+        return pg_affected_rows($result);
+    }
+
+    function query_and_fetch_all($query, $params = array()) {
+        if (!$this->connection)
+            return false;
+        $result = pg_query_params($this->connection, $query, $params);
+        if (!$result)
+            return false;
+        return pg_fetch_all($result);
+    }
+
+    function query($query, $params = array()) {
+        if (!$this->connection)
+            return FALSE;
+        return pg_query_params($this->connection, $query, $params);
+    }
+
+    function fetch_next_row($result) {
+        return pg_fetch_assoc($result);
+    }
+
+    function fetch_table($table_name, $column_to_be_ordered_by = null) {
+        if (!$this->connection || !ctype_alnum_underscore($table_name) || ($column_to_be_ordered_by && !ctype_alnum_underscore($column_to_be_ordered_by)))
+            return false;
+        $clauses = '';
+        if ($column_to_be_ordered_by)
+            $clauses .= 'ORDER BY ' . $column_to_be_ordered_by;
+        return $this->query_and_fetch_all("SELECT * FROM $table_name $clauses");
+    }
+
+    function begin_transaction() {
+        return $this->connection and pg_query($this->connection, "BEGIN");
+    }
+
+    function commit_transaction() {
+        return $this->connection and pg_query($this->connection, 'COMMIT');
+    }
+
+    function rollback_transaction() {
+        return $this->connection and pg_query($this->connection, 'ROLLBACK');
+    }
+
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/include/json-header.php b/Websites/perf.webkit.org/public/include/json-header.php
new file mode 100644 (file)
index 0000000..fcd2598
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+
+require_once('db.php');
+
+header('Content-type: application/json');
+$maxage = config('jsonCacheMaxAge');
+header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $maxage) . ' GMT');
+header("Cache-Control: maxage=$maxage");
+
+function exit_with_error($status, $details = array()) {
+    $details['status'] = $status;
+    merge_additional_details($details);
+
+    echo json_encode($details);
+    exit(1);
+}
+
+function echo_success($details = array()) {
+    $details['status'] = 'OK';
+    merge_additional_details($details);
+
+    echo json_encode($details);
+}
+
+function exit_with_success($details = array()) {
+    echo_success($details);
+    exit(0);
+}
+
+$additional_exit_details = array();
+
+function set_exit_detail($name, $value) {
+    global $additional_exit_details;
+    $additional_exit_details[$name] = $value;
+}
+
+function merge_additional_details(&$details) {
+    global $additional_exit_details;
+    foreach ($additional_exit_details as $name => $value) {
+        if (!array_key_exists($name, $details))
+            $details[$name] = $value;
+    }
+}
+
+function connect() {
+    $db = new Database;
+    if (!$db->connect())
+        exit_with_error('DatabaseConnectionError');
+    return $db;
+}
+
+function camel_case_words_separated_by_underscore($name) {
+    return implode('', array_map('ucfirst', explode('_', $name)));
+}
+
+function require_format($key, $value, $pattern) {
+    if (!preg_match($pattern, $value))
+        exit_with_error('Invalid' . camel_case_words_separated_by_underscore($key), array('value' => $value));
+}
+
+function require_existence_of($array, $list_of_arguments, $prefix = '') {
+    if ($prefix)
+        $prefix .= '_';
+    foreach ($list_of_arguments as $key => $pattern) {
+        $name = camel_case_words_separated_by_underscore($prefix . $key);
+        if (!array_key_exists($key, $array))
+            exit_with_error($name . 'NotSpecified');
+        require_format($name, $array[$key], $pattern);
+    }
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/include/manifest.php b/Websites/perf.webkit.org/public/include/manifest.php
new file mode 100644 (file)
index 0000000..1c7e4f3
--- /dev/null
@@ -0,0 +1,138 @@
+<?php
+
+class ManifestGenerator {
+    private $db;
+    private $manifest;
+
+    // FIXME: Compute this value from config.json
+    const MANIFEST_PATH = '../data/manifest.json';
+
+    function __construct($db) {
+        $this->db = $db;
+    }
+
+    function generate() {
+        $config_table = $this->db->fetch_table('test_configurations');
+        $platform_table = $this->db->fetch_table('platforms');
+        $repositories_table = $this->db->fetch_table('repositories');
+        $this->manifest = array(
+            'tests' => $this->tests(),
+            'metrics' => $this->metrics(),
+            'all' => $this->platforms($config_table, $platform_table, false),
+            'dashboard' => $this->platforms($config_table, $platform_table, true),
+            'repositories' => $this->repositories($repositories_table),
+            'builders' => $this->builders(),
+            'bugTrackers' => $this->bug_trackers($repositories_table),
+        );
+        return $this->manifest;
+    }
+
+    function store() {
+        return file_put_contents(self::MANIFEST_PATH, json_encode($this->manifest));
+    }
+
+    private function tests() {
+        $tests = array();
+        $tests_table = $this->db->fetch_table('tests');
+        if (!$tests_table)
+            return $tests;
+        foreach ($tests_table as $test_row) {
+            $tests[$test_row['test_id']] = array(
+                'name' => $test_row['test_name'],
+                'url' => $test_row['test_url'],
+                'parentId' => $test_row['test_parent'],
+            );
+        }
+        return $tests;
+    }
+
+    private function metrics() {
+        $metrics = array();
+        $metrics_table = $this->db->query_and_fetch_all('SELECT * FROM test_metrics LEFT JOIN aggregators ON metric_aggregator = aggregator_id');
+        if (!$metrics_table)
+            return $metrics;
+        foreach ($metrics_table as $row) {
+            $metrics[$row['metric_id']] = array(
+                'name' => $row['metric_name'],
+                'test' => $row['metric_test'],
+                'aggregator' => $row['aggregator_name']);
+        }
+        return $metrics;
+    }
+
+    private function platforms($config_table, $platform_table, $is_dashboard) {
+        $platform_metrics = array();
+        if ($config_table) {
+            foreach ($config_table as $config_row) {
+                if ($is_dashboard && !$this->db->is_true($config_row['config_is_in_dashboard']))
+                    continue;
+
+                $platform = &array_ensure_item_has_array($platform_metrics, $config_row['config_platform']);
+                if (!in_array($config_row['config_metric'], $platform))
+                    array_push($platform, $config_row['config_metric']);
+            }
+        }
+        $platforms = array();
+        if ($platform_table) {
+            foreach ($platform_table as $platform_row) {
+                if ($this->db->is_true($platform_row['platform_hidden']))
+                    continue;
+                $id = $platform_row['platform_id'];
+                if (array_key_exists($id, $platform_metrics))
+                    $platforms[$id] = array('name' => $platform_row['platform_name'], 'metrics' => $platform_metrics[$id]);
+            }
+        }
+        return $platforms;
+    }
+
+    private function repositories($repositories_table) {
+        $repositories = array();
+        if (!$repositories_table)
+            return $repositories;
+        foreach ($repositories_table as $row)
+            $repositories[$row['repository_name']] = array('url' => $row['repository_url'], 'blameUrl' => $row['repository_blame_url']);
+
+        return $repositories;
+    }
+
+    private function builders() {
+        $builders_table = $this->db->fetch_table('builders');
+        if (!$builders_table)
+            return array();
+        $builders = array();
+        foreach ($builders_table as $row)
+            $builders[$row['builder_id']] = array('name' => $row['builder_name'], 'buildUrl' => $row['builder_build_url']);
+
+        return $builders;
+    }
+
+    private function bug_trackers($repositories_table) {
+        $repository_id_to_name = array();
+        if ($repositories_table) {
+            foreach ($repositories_table as $row)
+                $repository_id_to_name[$row['repository_id']] = $row['repository_name'];
+        }
+
+        $tracker_id_to_repositories = array();
+        $tracker_repositories_table = $this->db->fetch_table('tracker_repositories');
+        if ($tracker_repositories_table) {
+            foreach ($tracker_repositories_table as $row) {
+                array_push(array_ensure_item_has_array($tracker_id_to_repositories, $row['tracrepo_tracker']),
+                    $repository_id_to_name[$row['tracrepo_repository']]);
+            }
+        }
+
+        $bug_trackers = array();
+        $bug_trackers_table = $this->db->fetch_table('bug_trackers');
+        if ($bug_trackers_table) {
+            foreach ($bug_trackers_table as $row) {
+                $bug_trackers[$row['tracker_name']] = array('newBugUrl' => $row['tracker_new_bug_url'],
+                    'repositories' => $tracker_id_to_repositories[$row['tracker_id']]);
+            }
+        }
+
+        return $bug_trackers;
+    }
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/include/report-processor.php b/Websites/perf.webkit.org/public/include/report-processor.php
new file mode 100644 (file)
index 0000000..110d4e4
--- /dev/null
@@ -0,0 +1,401 @@
+<?php
+
+require_once('../include/json-header.php');
+
+class ReportProcessor {
+    private $db;
+    private $name_to_aggregator;
+    private $report_id;
+    private $runs;
+
+    function __construct($db) {
+        $this->db = $db;
+        $this->name_to_aggregator = array();
+        $aggregator_table = $db->fetch_table('aggregators');
+        if ($aggregator_table) {
+            foreach ($aggregator_table as $aggregator_row) {
+                $this->name_to_aggregator[$aggregator_row['aggregator_name']] = $aggregator_row;
+            }
+        }
+    }
+
+    private function exit_with_error($message, $details = NULL) {
+        if (!$this->report_id) {
+            $details['failureStored'] = FALSE;
+            exit_with_error($message, $details);
+        }
+
+        $details['failureStored'] = $this->db->query_and_get_affected_rows(
+            'UPDATE reports SET report_failure = $1, report_failure_details = $2 WHERE report_id = $3',
+            array($message, $details ? json_encode($details) : NULL, $this->report_id)) == 1;
+        exit_with_error($message, $details);
+    }
+
+    function process($report, $existing_report_id = NULL) {
+        $this->report_id = $existing_report_id;
+        $this->runs = NULL;
+
+        array_key_exists('builderName', $report) or $this->exit_with_error('MissingBuilderName');
+        array_key_exists('buildTime', $report) or $this->exit_with_error('MissingBuildTime');
+
+        $builder_info = array('name' => $report['builderName']);
+        if (!$existing_report_id)
+            $builder_info['password_hash'] = hash('sha256', $report['builderPassword']);
+        if (array_key_exists('builderPassword', $report))
+            unset($report['builderPassword']);
+
+        $matched_builder = $this->db->select_first_row('builders', 'builder', $builder_info);
+        if (!$matched_builder)
+            $this->exit_with_error('BuilderNotFound', array('name' => $builder_info['name']));
+
+        $build_data = $this->construct_build_data($report, $matched_builder);
+        if (!$existing_report_id)
+            $this->store_report($report, $build_data);
+
+        $this->runs = new TestRunsGenerator($this->db, $this->name_to_aggregator, $this->report_id);
+        $this->recursively_ensure_tests($report['tests']);
+
+        $this->runs->aggregate();
+        $this->runs->compute_caches();
+
+        $platform_id = $this->db->select_or_insert_row('platforms', 'platform', array('name' => $report['platform']));
+        if (!$platform_id)
+            $this->exit_with_error('FailedToInsertPlatform', array('name' => $report['platform']));
+
+        $build_id = $this->resolve_build_id($build_data, array_get($report, 'revisions', array()));
+
+        $this->runs->commit($platform_id, $build_id);
+    }
+
+    private function construct_build_data($report, $builder) {
+        array_key_exists('buildNumber', $report) or $this->exit_with_error('MissingBuildNumber');
+        array_key_exists('buildTime', $report) or $this->exit_with_error('MissingBuildTime');
+
+        return array('builder' => $builder['builder_id'], 'number' => $report['buildNumber'], 'time' => $report['buildTime']);
+    }
+
+    private function store_report($report, $build_data) {
+        assert(!$this->report_id);
+        $this->report_id = $this->db->insert_row('reports', 'report', array('builder' => $build_data['builder'], 'build_number' => $build_data['number'],
+            'content' => json_encode($report)));
+        if (!$this->report_id)
+            $this->exit_with_error('FailedToStoreRunReport');
+    }
+
+    private function resolve_build_id($build_data, $revisions) {
+        // FIXME: This code has a race condition. See <rdar://problem/15876303>.
+        $results = $this->db->query_and_fetch_all("SELECT build_id FROM builds WHERE build_builder = $1 AND build_number = $2 AND build_time <= $3 AND build_time + interval '1 day' > $3",
+            array($build_data['builder'], $build_data['number'], $build_data['time']));
+        if ($results)
+            $build_id = $results[0]['build_id'];
+        else
+            $build_id = $this->db->insert_row('builds', 'build', $build_data);
+        if (!$build_id)
+            $this->exit_with_error('FailedToInsertBuild', $build_data);
+
+        foreach ($revisions as $repository_name => $revision_data) {
+            $repository_id = $this->db->select_or_insert_row('repositories', 'repository', array('name' => $repository_name));
+            if (!$repository_id)
+                $this->exit_with_error('FailedToInsertRepository', array('name' => $repository_name));
+
+            $revision_data = array('repository' => $repository_id, 'build' => $build_id, 'value' => $revision_data['revision'],
+                'time' => array_get($revision_data, 'timestamp'));
+            $revision_row = $this->db->select_or_insert_row('build_revisions', 'revision', array('repository' => $repository_id, 'build' => $build_id), $revision_data, '*');
+            if (!$revision_row)
+                $this->exit_with_error('FailedToInsertRevision', $revision_data);
+            if ($revision_row['revision_value'] != $revision_data['value'])
+                $this->exit_with_error('MismatchingRevisionData', array('existing' => $revision_row, 'new' => $revision_data));
+        }
+
+        return $build_id;
+    }
+
+    private function recursively_ensure_tests($tests, $parent_id = NULL, $level = 0) {
+        foreach ($tests as $test_name => $test) {
+            $test_id = $this->db->select_or_insert_row('tests', 'test', $parent_id ? array('name' => $test_name, 'parent' => $parent_id) : array('name' => $test_name),
+                array('name' => $test_name, 'parent' => $parent_id, 'url' => array_get($test, 'url')));
+            if (!$test_id)
+                $this->exit_with_error('FailedToAddTest', array('name' => $test_name, 'parent' => $parent_id));
+
+            if (array_key_exists('tests', $test))
+                $this->recursively_ensure_tests($test['tests'], $test_id, $level + 1);
+
+            foreach (array_get($test, 'metrics', array()) as $metric_name => $aggregators_or_config_types) {
+                $aggregators = $this->aggregator_list_if_exists($aggregators_or_config_types);
+                if ($aggregators) {
+                    foreach ($aggregators as $aggregator_name)
+                        $this->runs->add_aggregated_metric($parent_id, $test_id, $test_name, $metric_name, $aggregator_name, $level);
+                } else {
+                    $metric_id = $this->db->select_or_insert_row('test_metrics', 'metric', array('name' => $metric_name, 'test' => $test_id));
+                    if (!$metric_id)
+                        $this->exit_with_error('FailedToAddMetric', array('name' => $metric_name, 'test' => $test_id));
+
+                    foreach ($aggregators_or_config_types as $config_type => $values) {
+                        // Some tests submit groups of iterations; e.g. [[1, 2, 3, 4], [5, 6, 7, 8]]
+                        // Convert other tests to this format to simplify the computation later.
+                        if (gettype($values) !== 'array')
+                            $values = array($values);
+                        if (gettype($values[0]) !== 'array')
+                            $values = array($values);
+                        $this->runs->add_values_to_commit($metric_id, $config_type, $values);
+                        $this->runs->add_values_for_aggregation($parent_id, $test_name, $metric_name, $config_type, $values);
+                    }
+                }
+            }
+        }
+    }
+
+    private function aggregator_list_if_exists($aggregators_or_config_types) {
+        if (array_key_exists(0, $aggregators_or_config_types))
+            return $aggregators_or_config_types;
+        else if (array_get($aggregators_or_config_types, 'aggregators'))
+            return $aggregators_or_config_types['aggregators'];
+        return NULL;
+    }
+};
+
+class TestRunsGenerator {
+    private $db;
+    private $name_to_aggregator;
+    private $report_id;
+    private $metrics_to_aggregate;
+    private $parent_to_values;
+    private $values_to_commit;
+
+    function __construct($db, $name_to_aggregator, $report_id) {
+        $this->db = $db;
+        $this->name_to_aggregator = $name_to_aggregator or array();
+        $this->report_id = $report_id;
+        $this->metrics_to_aggregate = array();
+        $this->parent_to_values = array();
+        $this->values_to_commit = array();
+    }
+
+    private function exit_with_error($message, $details = NULL) {
+        $details['failureStored'] = $this->db->query_and_get_affected_rows(
+            'UPDATE reports SET report_failure = $1, report_failure_details = $2 WHERE report_id = $3',
+            array($message, $details ? json_encode($details) : NULL, $this->report_id)) == 1;
+        exit_with_error($message, $details);
+    }
+
+    function add_aggregated_metric($parent_id, $test_id, $test_name, $metric_name, $aggregator_name, $level) {
+        array_key_exists($aggregator_name, $this->name_to_aggregator) or $this->exit_with_error('AggregatorNotFound', array('name' => $aggregator_name));
+
+        $metric_id = $this->db->select_or_insert_row('test_metrics', 'metric', array('name' => $metric_name,
+            'test' => $test_id, 'aggregator' => $this->name_to_aggregator[$aggregator_name]['aggregator_id']));
+        if (!$metric_id)
+            $this->exit_with_error('FailedToAddAggregatedMetric', array('name' => $metric_name, 'test' => $test_id, 'aggregator' => $aggregator_name));
+
+        array_push($this->metrics_to_aggregate, array(
+            'test_id' => $test_id,
+            'parent_id' => $parent_id,
+            'metric_id' => $metric_id,
+            'test_name' => $test_name,
+            'metric_name' => $metric_name,
+            'aggregator' => $aggregator_name,
+            'aggregator_definition' => $this->name_to_aggregator[$aggregator_name]['aggregator_definition'],
+            'level' => $level));
+    }
+
+    function add_values_for_aggregation($parent_id, $test_name, $metric_name, $config_type, $values, $aggregator = NULL) {
+        $value_list = &array_ensure_item_has_array(array_ensure_item_has_array(array_ensure_item_has_array(array_ensure_item_has_array(
+            $this->parent_to_values, strval($parent_id)), $metric_name), $config_type), $test_name);
+        array_push($value_list, array('aggregator' => $aggregator, 'values' => $values));
+    }
+
+    function aggregate() {
+        $expressions = array();
+        foreach ($this->metrics_to_aggregate as $test_metric) {
+            $configurations = array_get(array_get($this->parent_to_values, strval($test_metric['test_id']), array()), $test_metric['metric_name']);
+            foreach ($configurations as $config_type => $test_value_list) {
+                // FIXME: We should preserve the test order. For that, we need a new column in our database.
+                $values_by_iteration = $this->test_value_list_to_values_by_iterations($test_value_list, $test_metric, $test_metric['aggregator']);
+                $flattened_aggregated_values = array();
+                for ($i = 0; $i < count($values_by_iteration['values']); ++$i)
+                    array_push($flattened_aggregated_values, $this->aggregate_values($test_metric['aggregator'], $values_by_iteration['values'][$i]));
+
+                $grouped_values = array();
+                foreach ($values_by_iteration['group_sizes'] as $size) {
+                    $new_group = array();
+                    for ($i = 0; $i < $size; ++$i)
+                        array_push($new_group, array_shift($flattened_aggregated_values));
+                    array_push($grouped_values, $new_group);
+                }
+
+                $this->add_values_to_commit($test_metric['metric_id'], $config_type, $grouped_values);
+                $this->add_values_for_aggregation($test_metric['parent_id'], $test_metric['test_name'], $test_metric['metric_name'],
+                    $config_type, $grouped_values, $test_metric['aggregator']);
+            }
+        }
+    }
+
+    private function test_value_list_to_values_by_iterations($test_value_list, $test_metric, $aggregator) {
+        $values_by_iterations = array();
+        $group_sizes = array();
+        $first_test = TRUE;
+        foreach ($test_value_list as $test_name => $aggregators_and_values) {
+            if (count($aggregators_and_values) == 1) // Either the subtest has exactly one aggregator or is raw value (not aggregated)
+                $values = $aggregators_and_values[0]['values'];
+            else {
+                $values = NULL;
+                // Find the values of the subtest aggregated by the same aggregator.
+                foreach ($aggregators_and_values as $aggregator_and_values) {
+                    if ($aggregator_and_values['aggregator'] == $aggregator) {
+                        $values = $aggregator_and_values['values'];
+                        break;                        
+                    }
+                }
+                if (!$values) {
+                    $this->exit_with_error('NoMatchingAggregatedValueInSubtest',
+                        array('parent' => $test_metric['test_id'],
+                        'metric' => $test_metric['metric_name'],
+                        'childTest' => $test_name,
+                        'aggregator' => $aggregator,
+                        'aggregatorAndValues' => $aggregators_and_values));
+                }
+            }
+
+            for ($group = 0; $group < count($values); ++$group) {
+                if ($first_test) {
+                    array_push($group_sizes, count($values[$group]));
+                    for ($i = 0; $i < count($values[$group]); ++$i)
+                        array_push($values_by_iterations, array());
+                }
+
+                if ($group_sizes[$group] != count($values[$group])) {
+                    $this->exit_with_error('IterationGroupSizeIsInconsistent',
+                        array('parent' => $test_metric['test_id'],
+                        'metric' => $test_metric['metric_name'],
+                        'childTest' => $test_name,
+                        'groupSizes' => $group_sizes,
+                        'group' => $group,
+                        'values' => $values));
+                }
+            }
+            $first_test = FALSE;
+
+            if (count($values) != count($group_sizes)) {
+                // FIXME: We should support bootstrapping or just computing the mean in this case.
+                $this->exit_with_error('IterationGroupCountIsInconsistent', array('parent' => $test_metric['test_id'],
+                    'metric' => $test_metric['metric_name'], 'childTest' => $name_and_values['name'],
+                    'valuesByIterations' => $values_by_iterations, 'values' => $values));
+            }
+
+            $flattened_iteration_index = 0;
+            for ($group = 0; $group < count($values); ++$group) {
+                for ($i = 0; $i < count($values[$group]); ++$i) {
+                    $run_iteration_value = $values[$group][$i];
+                    if (!is_numeric($run_iteration_value)) {
+                        $this->exit_with_error('NonNumeralIterationValueForAggregation', array('parent' => $test_metric['test_id'],
+                            'metric' => $test_metric['metric_name'], 'childTest' => $name_and_values['name'],
+                            'valuesByIterations' => $values_by_iterations, 'values' => $values, 'index' => $i));
+                    }
+                    array_push($values_by_iterations[$flattened_iteration_index], $run_iteration_value);
+                    $flattened_iteration_index++;
+                }
+            }
+        }
+
+        if (!$values_by_iterations)
+            $this->exit_with_error('NoIterationToAggregation', array('parent' => $test_metric['test_id'], 'metric' => $test_metric['metric_name']));
+
+        return array('values' => $values_by_iterations, 'group_sizes' => $group_sizes);
+    }
+
+    private function aggregate_values($aggregator, $values) {
+        switch ($aggregator) {
+        case 'Arithmetic':
+            return array_sum($values) / count($values);
+        case 'Geometric':
+            return pow(array_product($values), 1 / count($values));
+        case 'Harmonic':
+            return count($values) / array_sum(array_map(function ($x) { return 1 / $x; }, $values));
+        case 'Total':
+            return array_sum($values);
+        case 'SquareSum':
+            return array_sum(array_map(function ($x) { return $x * $x; }, $values));
+        default:
+            $this->exit_with_error('UnknownAggregator', array('aggregator' => $aggregator));
+        }
+        return NULL;
+    }
+
+    function compute_caches() {
+        $expressions = array();
+        $size = count($this->values_to_commit);
+        for ($i = 0; $i < $size; ++$i) {
+            $flattened_value = array();
+            foreach ($this->values_to_commit[$i]['values'] as $group) {
+                for ($j = 0; $j < count($group); ++$j) {
+                    $iteration_value = $group[$j];
+                    if (gettype($iteration_value) === 'array') { // [relative time, value]
+                        if (count($iteration_value) != 2) {
+                            // FIXME: Also report test and metric.
+                            $this->exit_with_error('InvalidIterationValueFormat', array('values' => $this->values_to_commit[$i]['values']));
+                        }
+                        $iteration_value = $iteration_value[1];
+                    }
+                    array_push($flattened_value, $iteration_value);                    
+                }
+            }
+            $this->values_to_commit[$i]['mean'] = $this->aggregate_values('Arithmetic', $flattened_value);
+            $this->values_to_commit[$i]['sum'] = $this->aggregate_values('Total', $flattened_value);
+            $this->values_to_commit[$i]['square_sum'] = $this->aggregate_values('SquareSum', $flattened_value);
+        }
+    }
+
+    function add_values_to_commit($metric_id, $config_type, $values) {
+        array_push($this->values_to_commit, array('metric_id' => $metric_id, 'config_type' => $config_type, 'values' => $values));
+    }
+
+    function commit($platform_id, $build_id) {
+        $this->db->begin_transaction() or $this->exit_with_error('FailedToBeginTransaction');
+
+        foreach ($this->values_to_commit as $item) {
+            $config_data = array('metric' => $item['metric_id'], 'type' => $item['config_type'], 'platform' => $platform_id);
+            $config_id = $this->db->select_or_insert_row('test_configurations', 'config', $config_data);
+            if (!$config_id)
+                $this->rollback_with_error('FailedToObtainConfiguration', $config_data);
+
+            $values = $item['values'];
+            $total_count = 0;
+            for ($group = 0; $group < count($values); ++$group)
+                $total_count += count($values[$group]);
+            $run_data = array('config' => $config_id, 'build' => $build_id, 'iteration_count_cache' => $total_count,
+                'mean_cache' => $item['mean'], 'sum_cache' => $item['sum'], 'square_sum_cache' => $item['square_sum']);
+            $run_id = $this->db->insert_row('test_runs', 'run', $run_data);
+            if (!$run_id)
+                $this->rollback_with_error('FailedToInsertRun', array('metric' => $item['metric_id'], 'param' => $run_data));
+
+            $flattened_order = 0;
+            for ($group = 0; $group < count($values); ++$group) {
+                for ($i = 0; $i < count($values[$group]); ++$i) {
+                    $iteration_value = $values[$group][$i];
+                    $relative_time = NULL;
+                    if (gettype($iteration_value) === 'array') {
+                        assert(count($iteration_value) == 2); // compute_caches checks this condition.
+                        $relative_time = $iteration_value[0];
+                        $iteration_value = $iteration_value[1];
+                    }
+                    $param = array('run' => $run_id, 'order' => $flattened_order, 'value' => $iteration_value,
+                        'group' => count($values) == 1 ? NULL : $group, 'relative_time' => $relative_time);
+                    $this->db->insert_row('run_iterations', 'iteration', $param, NULL)
+                        or $this->rollback_with_error('FailedToInsertIteration', array('config' => $config_id, 'build' => $build_id, 'param' => $param));
+                    $flattened_order++;
+                }
+            }
+        }
+
+        $this->db->query_and_get_affected_rows("UPDATE reports SET report_committed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+            WHERE report_id = $1", array($this->report_id));
+
+        $this->db->commit_transaction() or $this->exit_with_error('FailedToCommitTransaction');
+    }
+
+    private function rollback_with_error($message, $details) {
+        $this->db->rollback_transaction();
+        $this->exit_with_error($message, $details);
+    }
+};
+
+?>
diff --git a/Websites/perf.webkit.org/public/include/test-name-resolver.php b/Websites/perf.webkit.org/public/include/test-name-resolver.php
new file mode 100644 (file)
index 0000000..47896bf
--- /dev/null
@@ -0,0 +1,134 @@
+<?php
+
+class TestNameResolver {
+
+    function __construct($db) {
+        $this->db = $db;
+        $this->full_name_to_test = array();
+        $this->test_id_to_child_metrics = array();
+        $this->test_to_metrics = array();
+        $this->tests_sorted_by_full_name = array();
+        $this->metric_to_configurations = array();
+        $this->id_to_metric = array();
+        $this->id_to_aggregator = array();
+
+        $test_table = $db->fetch_table('tests');
+        if (!$test_table)
+            return;
+
+        $test_id_to_name = array();
+        $test_id_to_parent = array();
+        foreach ($test_table as $test_row) {
+            $test_id = $test_row['test_id'];
+            $test_id_to_name[$test_id] = $test_row['test_name'];
+            $test_id_to_parent[$test_id] = $test_row['test_parent'];
+        }
+
+        $this->full_name_to_test = $this->compute_full_name($test_table, $test_id_to_name, $test_id_to_parent);
+        $this->test_to_metrics = $this->map_metrics_to_tests($db->fetch_table('test_metrics'), $test_id_to_parent);
+        $this->tests_sorted_by_full_name = $this->sort_tests_by_full_name($this->full_name_to_test);
+
+        if ($configurations = $db->fetch_table('test_configurations')) {
+            foreach ($configurations as $config) {
+                $metric_id = $config['config_metric'];
+                $platform_id = $config['config_platform'];
+                array_set_default($this->metric_to_configurations, $metric_id, array());
+                array_set_default($this->metric_to_configurations[$metric_id], $platform_id, array());
+                array_push($this->metric_to_configurations[$metric_id][$platform_id], $config);
+            }
+        }
+
+        if ($aggregator_table = $db->fetch_table('aggregators')) {
+            foreach ($aggregator_table as $aggregator)
+                $this->id_to_aggregator[$aggregator['aggregator_id']] = $aggregator;
+        }
+    }
+
+    private function compute_full_name($test_table, $test_id_to_name, $test_id_to_parent) {
+        $full_name_to_test = array();
+        foreach ($test_table as $test_row) {
+            $test_path = array();
+            $test_id = $test_row['test_id'];
+            do {
+                array_push($test_path, $test_id_to_name[$test_id]);
+                $test_id = $test_id_to_parent[$test_id];
+            } while ($test_id);
+            $test_row['full_name'] = join('/', array_reverse($test_path));
+            $full_name_to_test[$test_row['full_name']] = $test_row;
+        }
+        return $full_name_to_test;
+    }
+
+    private function map_metrics_to_tests($metrics_table, $test_id_to_parent) {
+        $test_to_metrics = array();
+        if (!$metrics_table)
+            return $test_to_metrics;
+
+        foreach ($metrics_table as $metric_row) {
+            $this->id_to_metric[$metric_row['metric_id']] = $metric_row;
+
+            $test_id = $metric_row['metric_test'];
+            array_set_default($test_to_metrics, $test_id, array());
+            array_push($test_to_metrics[$test_id], $metric_row);
+
+            $parent_id = $test_id_to_parent[$test_id];
+            if ($parent_id) {
+                array_set_default($this->test_id_to_child_metrics, $parent_id, array());
+                $parent_metrics = &$this->test_id_to_child_metrics[$parent_id];
+                if (!in_array($metric_row['metric_name'], $parent_metrics))
+                    array_push($parent_metrics, $metric_row['metric_name']);
+            }
+        }
+        return $test_to_metrics;
+    }
+
+    private function sort_tests_by_full_name($full_name_to_test) {
+        $tests_sorted_by_full_name = array();
+        $full_names = array_keys($full_name_to_test);
+        asort($full_names);
+        foreach ($full_names as $name)
+            array_push($tests_sorted_by_full_name, $full_name_to_test[$name]);
+        return $tests_sorted_by_full_name;
+    }
+
+    function tests() {
+        return $this->tests_sorted_by_full_name;
+    }
+
+    function test_id_for_full_name($full_name) {
+        return array_get($this->full_name_to_test, $full_name);
+    }
+
+    function full_name_for_test($test_id) {
+        foreach ($this->full_name_to_test as $full_name => $test) {
+            if ($test['test_id'] == $test_id)
+                return $full_name;
+        }
+        return NULL;
+    }
+
+    function full_name_for_metric($metric_id) {
+        $metric_row = array_get($this->id_to_metric, $metric_id);
+        if (!$metric_row)
+            return NULL;
+        $full_name = $this->full_name_for_test($metric_row['metric_test']) . ':' . $metric_row['metric_name'];
+        if ($aggregator_id = $metric_row['metric_aggregator'])
+            $full_name .= ':' . $this->id_to_aggregator[$aggregator_id]['aggregator_name'];
+        return $full_name;
+    }
+
+    function metrics_for_test_id($test_id) {
+        return array_get($this->test_to_metrics, $test_id, array());
+    }
+
+    function child_metrics_for_test_id($test_id) {
+        return array_get($this->test_id_to_child_metrics, $test_id, array());
+    }
+
+    function configurations_for_metric_and_platform($metric_id, $platform_id) {
+        $metric_configurations = array_get($this->metric_to_configurations, $metric_id, array());
+        return array_get($metric_configurations, $platform_id);
+    }
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/index.html b/Websites/perf.webkit.org/public/index.html
new file mode 100644 (file)
index 0000000..1d32213
--- /dev/null
@@ -0,0 +1,1167 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>WebKit Perf Monitor</title>
+<script src="js/jquery.js" defer></script>
+<script src="js/jquery.flot.js" defer></script>
+<script src="js/jquery.flot.crosshair.js" defer></script>
+<script src="js/jquery.flot.fillbetween.js" defer></script>
+<script src="js/jquery.flot.resize.js" defer></script>
+<script src="js/jquery.flot.selection.js" defer></script>
+<script src="js/jquery.flot.time.js" defer></script>
+<script src="js/helper-classes.js" defer></script>
+<script src="js/shared.js" defer></script>
+<script src="js/statistics.js" defer></script>
+<link rel="stylesheet" href="common.css">
+<style type="text/css">
+
+#numberOfDaysPicker {
+    display: inline-block;
+    margin: 5px 0px;
+    border: solid 1px #ccc;
+    color: #666;
+    border-radius: 5px;
+    padding: 5px 8px;
+}
+
+#numberOfDaysPicker input {
+    height: 0.9em;
+    margin-right: 1em;
+}
+
+td, th {
+    border: none;
+    border-collapse: collapse;
+}
+
+#dashboard > tbody > tr > td {
+    vertical-align: top;
+}
+
+#dashboard > thead th {
+    padding-top: 1.5em;
+    text-shadow: #bbb 1px 1px 2px;
+    font-size: large;
+    font-weight: normal;
+}
+
+#dashboard td {
+    
+}
+
+.chart {
+    position: relative;
+    border: solid 1px #ccc;
+    border-radius: 5px;
+    margin: 10px 10px 0px 0px;
+}
+
+.chart.worse {
+    background: #fcc;
+}
+
+.chart.better {
+    background: #cfc;
+}
+
+#charts .pane {
+    position: absolute;
+    left: 10px;
+    top: 10px;
+    width: 200px;
+}
+
+.chart header {
+    height: 3em;
+    display: table-cell;
+    vertical-align: middle;
+}
+
+#dashboard header {
+    padding: 10px;
+    width: 200px;
+}
+
+.chart h2, .chart h3 {
+    margin: 0 0 0.3em 0;
+    padding: 0;
+    font-size: 1em;
+    font-weight: normal;
+    width: 200px;
+    word-break: break-all;
+}
+
+.chart h2 {
+    font-size: normal;
+}
+
+.chart h3 {
+    font-size: normal;
+}
+
+#dashboard .chart h3 {
+    display: none;
+}
+
+.chart .status {
+    font-size: small;
+    color: #666;
+}
+
+.plot {
+    margin: 5px 5px 10px 5px;
+}
+
+.closeButton svg {
+    position: absolute;
+    left: 8px;
+    bottom: 8px;
+    width: 15px;
+    height: 15px;
+}
+
+#dashboard .plot {
+    width: 400px;
+    height: 100px;
+    cursor: pointer;
+    cursor: hand;
+}
+
+#charts .plot {
+    height: 300px;
+    margin-left: 250px;
+}
+
+#dashboard .overviewPlot {
+    display: none;
+}
+
+#charts .overviewPlot {
+    margin: 10px 0px 0px 0px;
+    padding: 0;
+    height: 70px;
+}
+
+.summaryTable {
+    font-size: small;
+    color: #666;
+    border: 0;
+}
+
+#dashboard .summaryTable {
+    position: absolute;
+    right: 10px;
+    top: 10px;
+    width: 180px;
+}
+
+#dashboard .arrow {
+    width: 20px;
+    height: 40px;
+    position: absolute;
+    bottom: 50px;
+    left: 5px;
+}
+
+#dashboard .unit {
+    display: none;
+}
+
+#charts .unit {
+    font-size: small;
+    width: 50px;
+    text-align: center;
+    position: absolute;
+    bottom: 10px;
+    left: 245px;
+}
+
+#charts .arrow {
+    width: 20px;
+    height: 40px;
+    position: absolute;
+    top: 10px;
+    left: 220px;
+}
+
+.chart svg {
+    stroke: #ccc;
+    fill: #ccc;
+    color: #ccc;
+}
+
+.chart.worse svg {
+    stroke: #c99;
+    fill: #c99;
+    color: #c99;
+}
+
+.chart.better svg {
+    stroke: #9c9;
+    fill: #9c9;
+    color: #9c9;
+}
+
+.chart .yaxistoggler {
+    position: absolute;
+    bottom: 10px;
+    left: 225px;
+    width: 10px;
+    height: 25px;
+}
+
+#dashboard  .yaxistoggler {
+    display: none;
+}
+
+.tooltip {
+    position: relative;
+    border-radius: 5px;
+    padding: 5px;
+    opacity: 0.8;
+    background: #333;
+    color: #eee;
+    font-size: small;
+    line-height: 130%;
+    width: 30ex;
+}
+
+.tooltip:after {
+    position: absolute;
+    width: 0;
+    height: 0;
+    left: 50%;
+    margin-left: -9px;
+    bottom: -19px;
+    content: "";
+    display: block;
+    border-style: solid;
+    border-width: 10px;
+    border-color: #333 transparent transparent transparent;
+}
+
+.tooltip a {
+    text-decoration: underline;
+    color: #fff;
+    text-shadow: none;
+}
+
+.clickTooltip {
+    opacity: 0.6;
+}
+
+.hoverTooltip {
+    z-index: 99999;
+}
+
+#testPicker {
+    display: inline-block;
+    margin: 10px 0px;
+    border: solid 1px #ccc;
+    color: #666;
+    border-radius: 5px;
+    padding: 5px 8px;
+}
+
+#numberOfDaysPicker {
+    display: inline-block;
+    margin: 5px 0px;
+    border: solid 1px #ccc;
+    color: #666;
+    border-radius: 5px;
+    padding: 5px 8px;
+}
+
+#numberOfDaysPicker input {
+    height: 0.9em;
+    margin-right: 1em;
+}
+
+</style>
+<script>
+
+(function () {
+    var charts = [];
+    var minTime;
+    var currentZoom;
+    var sharedPlotOptions = {
+        lines: { show: true, lineWidth: 1 },
+        xaxis: {
+            mode: "time",
+            timeformat: "%m/%d",
+            minTickSize: [1, 'day'],
+            max: Date.now(), // FIXME: This is likely broken for non-PST
+        },
+        yaxis: { tickLength: 0 },
+        series: { shadowSize: 0 },
+        points: { show: false },
+        grid: {
+            hoverable: true,
+            borderWidth: 1,
+            borderColor: '#999',
+            backgroundColor: '#fff',
+        }
+    };
+
+    function computeYAxisBoundsToFitLines(minTime, results, baseline, target) {
+        var stdevOfAllRuns = results.sampleStandardDeviation(minTime);
+        var movingAverage = results.exponentialMovingArithmeticMean(minTime, /* alpha, the degree of weighting decrease */ 0.3);
+        var min = results.min(minTime);
+        var max = results.max(minTime);
+
+        if (baseline) {
+            min = Math.min(min, baseline.min(minTime));
+            max = Math.max(max, baseline.max(minTime));
+        }
+        if (target) {
+            min = Math.min(min, target.min(minTime));
+            max = Math.max(max, target.max(minTime));
+        }
+
+        var marginSize = (max - min) * 0.1;
+        return {min: min - marginSize, max: max + marginSize,
+            adjustedMin: Math.min(results.lastResult().mean() - marginSize, Math.max(movingAverage - stdevOfAllRuns * 2, min) - marginSize),
+            adjustedMax: Math.max(results.lastResult().mean() + marginSize, Math.min(movingAverage + stdevOfAllRuns * 2, max) + marginSize) };
+    }
+
+    function computeStatus(smallerIsBetter, lastResult, baseline, target) {
+        var relativeDifferenceWithBaseline = baseline ? lastResult.relativeDifference(baseline.lastResult()) : 0;
+        var relativeDifferenceWithTarget = target ? lastResult.relativeDifference(target.lastResult()) : 0;
+        var statusText = '';
+        var status = '';
+
+        if (relativeDifferenceWithBaseline && relativeDifferenceWithBaseline > 0 != smallerIsBetter) {
+            statusText = Math.abs(relativeDifferenceWithBaseline * 100).toFixed(2) + '% ' + (smallerIsBetter ? 'above' : 'below') + ' baseline';
+            status = 'worse';
+        } else if (relativeDifferenceWithTarget && relativeDifferenceWithTarget > 0 == smallerIsBetter) {
+            statusText = Math.abs(relativeDifferenceWithTarget * 100).toFixed(2) + '% ' + (smallerIsBetter ? 'below' : 'above') + ' target';
+            status = 'better';
+        } else if (relativeDifferenceWithTarget)
+            statusText = Math.abs(relativeDifferenceWithTarget * 100).toFixed(2) + '% until target';
+
+        if (statusText)
+            statusText += '<br>';
+        statusText += buildLabelWithLinks(lastResult.build());
+
+        return {class: status, text: statusText};
+    }
+
+    function addPlotDataForRun(plotData, name, runs, color, interactive) {
+        var entry = {color: color, data: runs.meanPlotData()};
+        if (!interactive) {
+            entry.clickable = false;
+            entry.hoverable = false;
+        }
+        if (runs.hasConfidenceInterval()) {
+            var lowerName = name.toLowerCase();
+            var confienceEntry = $.extend(true, {}, entry, {lines: {lineWidth: 0}, clickable: false, hoverable: false});
+            plotData.push($.extend(true, {}, confienceEntry, {id: lowerName, data: runs.upperConfidencePlotData()}));
+            plotData.push($.extend(true, {}, confienceEntry,
+                {fillBetween: lowerName, lines: {fill: 0.3}, data: runs.lowerConfidencePlotData()}));
+        }
+        plotData.push(entry);
+    }
+
+    function createSummaryRowMarkup(name, runs) {
+        return '<tr><td>' + name + '</td><td>' + runs.lastResult().label() + '</td></tr>';
+    }
+
+    function buildLabelWithLinks(build, previousBuild) {
+        function linkifyIfNotNull(label, url) {
+            return url ? '<a href="' + url + '" target="_blank">' + label + '</a>' : label;
+        }
+
+        var formattedRevisions = build.formattedRevisions(previousBuild);
+        var markup = ['Committed: ' + build.formattedTime(),
+            'Build: ' + linkifyIfNotNull(build.buildNumber(), build.buildUrl()) + ' (' + build.formattedBuildTime() + ')'];
+        for (var repositoryName in formattedRevisions)
+            markup.push(linkifyIfNotNull(formattedRevisions[repositoryName].label, formattedRevisions[repositoryName].url));
+        return markup.join('<br>');
+    }
+
+    function Chart(container, isDashboard, platform, metric, bugTrackers, onClose) {
+        var linkifiedFullName = metric.fullName;
+        if (metric.test.url)
+            linkifiedFullName = '<a href="' + metric.test.url + '">' + linkifiedFullName + '</a>';
+        var section = $('<section class="chart"><div class="pane"><header><h2>' + linkifiedFullName + '</h2>'
+            + '<h3 class="platform">' + platform.name + '</h3>'
+            + '<span class="status"></span></header>'
+            + '<table class="summaryTable"><tbody></tbody></table>'
+            + '<div class="overviewPlot"></div></div>'
+            + '<div class="plot"></div>'
+            + '<div class="unit"></div>'
+            + '<svg viewBox="0 0 20 100" class="arrow">'
+            + '<g stroke-width="10"><line x1="10" y1="8" x2="10" y2="92" />'
+            + '<polygon points="5,85 15,85 10,90" class="downwardArrowHead" />'
+            + '<polygon points="5,15 15,15 10,10" class="upwardArrowHead" />'
+            + '</g></svg>'
+            + '<a href="#" class="toggleYAxis"><svg viewBox="0 0 40 100" class="yaxistoggler"><g stroke-width="10">'
+            + '<line x1="20" y1="8" x2="20" y2="82" />'
+            + '<polygon points="15,15 25,15 20,10" />'
+            + '<polygon points="15,75 25,75 20,80" />'
+            + '<line x1="0" y1="92" x2="40" y2="92" />'
+            + '</g></svg></a>'
+            + '<a href="#" class="closeButton"><svg viewBox="0 0 100 100">'
+            + '<g stroke-width="10"><circle cx="50" cy="50" r="45" fill="transparent"/><polygon points="30,30 70,70" />'
+            + '<polygon points="30,70 70,30" /></g></svg></a></section>');
+
+        $(container).append(section);
+
+        var self = this;
+        if (onClose) {
+            section.find('.closeButton').bind('click', function (event) {
+                event.preventDefault();
+                section.remove();
+                charts.splice(charts.indexOf(self), 1);
+                onClose(self);
+                return false;
+            });
+        } else
+            section.find('.closeButton').hide();
+
+        section.find('.yaxistoggler').bind('click', function (event) {
+            self.toggleYAxis();
+            event.preventDefault();
+            return false;
+        });
+
+        var plotData = [];
+        var results;
+        var baseline;
+        var target;
+
+        var tooltip = new Tooltip(container, 'tooltip hoverTooltip');
+        var bounds;
+        var plotContainer = section.find('.plot');
+        var mainPlot;
+        var overviewPlot;
+        var clickTooltips = [];
+        var shouldShowEntireYAxis = false;
+
+        this.platform = function () { return platform; }
+        this.metric = function () { return metric; }
+
+        this.populate = function (passedResults, passedBaseline, passedTarget) {
+            results = passedResults;
+            baseline = passedBaseline;
+            target = passedTarget;
+
+            var summaryRows = '';
+            if (target) {
+                addPlotDataForRun(plotData, 'Target', target, '#039');
+                summaryRows = createSummaryRowMarkup('Target', target) + summaryRows;
+            }
+            if (baseline) {
+                addPlotDataForRun(plotData, 'Baseline', baseline, '#930');
+                summaryRows = createSummaryRowMarkup('Baseline', baseline) + summaryRows;
+            }
+            addPlotDataForRun(plotData, 'Current', results, '#666', true);
+            summaryRows = createSummaryRowMarkup('Current', results) + summaryRows;
+
+            var status = computeStatus(results.smallerIsBetter(), results.lastResult(), baseline, target);
+            section.addClass(status.class);
+            section.find('.status').html(status.text);
+            section.find('.summaryTable tbody').html(summaryRows);
+            section.find('.unit').html(results.unit());
+            if (results.smallerIsBetter()) {
+                section.find('.arrow .downwardArrowHead').show();
+                section.find('.arrow .upwardArrowHead').hide();
+            } else {
+                section.find('.arrow .downwardArrowHead').hide();
+                section.find('.arrow .upwardArrowHead').show();
+            }
+        }
+
+        this.attachMainPlot = function (xMin, xMax) {
+            if (!bounds)
+                return;
+
+            var mainPlotOptions = $.extend(true, {}, sharedPlotOptions, {
+                xaxis: {
+                    min: xMin,
+                },
+                yaxis: {
+                    tickLength: null,
+                    min: shouldShowEntireYAxis ? 0 : bounds.adjustedMin,
+                    max: shouldShowEntireYAxis ? bounds.max : bounds.adjustedMax,
+                },
+                crosshair: {'mode': 'x', color: '#c90', lineWidth: 1},
+                grid: {clickable: true},
+            });
+            if (xMax)
+                mainPlotOptions.xaxis.max = xMax;
+            if (isDashboard) {
+                mainPlotOptions.yaxis.labelWidth = 20;
+                mainPlotOptions.yaxis.ticks = [mainPlotOptions.yaxis.min, mainPlotOptions.yaxis.max];
+                mainPlotOptions.grid.autoHighlight = false;
+            } else {
+                mainPlotOptions.yaxis.labelWidth = 30;
+                mainPlotOptions.selection = {mode: "x"};
+                plotData[plotData.length - 1].points = {show: true, radius: 1};
+            }
+            mainPlot = $.plot(plotContainer, plotData, mainPlotOptions);
+            plotData[plotData.length - 1].points = {};
+
+            for (var i = 0; i < clickTooltips.length; i++) {
+                if (clickTooltips[i])
+                    clickTooltips[i].remove();
+            }
+            clickTooltips = [];
+        }
+
+        this.toggleYAxis = function () {
+            shouldShowEntireYAxis = !shouldShowEntireYAxis;
+            if (currentZoom)
+                this.attachMainPlot(currentZoom.from, currentZoom.to);
+            else
+                this.attachMainPlot(minTime);
+        }
+
+        this.zoom = function (from, to) {
+            this.attachMainPlot(from, to);
+            if (overviewPlot)
+                overviewPlot.setSelection({xaxis: {from: from, to: to}}, true);
+        }
+
+        this.clearZoom = function () {
+            this.attachMainPlot(minTime);
+            if (overviewPlot)
+                overviewPlot.clearSelection();
+        }
+        
+        this.setCrosshair = function (pos) {
+            if (mainPlot)
+                mainPlot.setCrosshair(pos);
+        }
+
+        this.clearCrosshair = function () {
+            if (mainPlot)
+                mainPlot.clearCrosshair();
+        }
+
+        this.hideTooltip = function() {
+            if (tooltip)
+                tooltip.hide();
+        }
+
+        function toggleClickTooltip(index, pageX, pageY) {
+            if (clickTooltips[index])
+                clickTooltips[index].toggle();
+            else {
+                // FIXME: Put this on URLState.
+                var newTooltip = new Tooltip(container, 'tooltip clickTooltip');
+                showTooltipWithResults(newTooltip, pageX, pageY, results.resultAt(index), results.resultAt(index - 1));
+                newTooltip.bindClick(function () { toggleClickTooltip(index, pageX, pageY); });
+                newTooltip.bindMouseEnter(function () { tooltip.hide(); });
+                clickTooltips[index] = newTooltip;
+            }
+            tooltip.hide();
+        }
+
+        function showTooltipWithResults(tooltip, x, y, result, resultToCompare) {
+            var newBugUrls = '';
+            if (resultToCompare) {
+                var title = (resultToCompare.isBetterThan(result) ? 'REGRESSION: ' : '') + result.metric().fullName
+                    + ' got ' + result.formattedProgressionOrRegression(resultToCompare)
+                    + ' around ' + result.build().formattedTime();
+                var revisions = result.build().formattedRevisions(resultToCompare.build());
+
+                for (var trackerName in bugTrackers) {
+                    var repositories = bugTrackers[trackerName].repositories;
+                    var description = 'Platform: ' + result.build().platform().name + '\n\n';
+                    for (var i = 0; i < repositories.length; ++i) {
+                        var repositoryName = repositories[i];
+                        var revision = revisions[repositoryName];
+                        if (!revision)
+                            continue;
+                        if (revision.url)
+                            description += repositoryName + ': ' + revision.url;
+                        else
+                            description += revision.label;
+                        description += '\n';
+                    }
+                    var url = bugTrackers[trackerName].newBugUrl
+                        .replace(/\$title/g, encodeURIComponent(title))
+                        .replace(/\$description/g, encodeURIComponent(description))
+                        .replace(/\$link/g, encodeURIComponent(location.href));
+                    if (newBugUrls)
+                        newBugUrls += ',';
+                    newBugUrls += ' <a href="' + url + '" target="_blank">' + trackerName + '</a>';
+                }
+                newBugUrls = 'File:' + newBugUrls;
+            }
+            tooltip.show(x, y, result.label(resultToCompare) + '<br>'
+                + buildLabelWithLinks(result.build(), resultToCompare ? resultToCompare.build() : null) + '<br>'
+                + newBugUrls);
+        }
+
+        tooltip.bindClick(function () {
+            if (tooltip.currentItem)
+                toggleClickTooltip(tooltip.currentItem.dataIndex, tooltip.currentItem.pageX, tooltip.currentItem.pageY);
+        });
+
+        function closestItemForPageXRespectingPlotOffset(item, plot, series, pageX) {
+            if (!series || !series.data.length)
+                return null;
+
+            var offset = $(plotContainer).offset();
+            var points = series.datapoints.points;
+            var size = series.datapoints.pointsize;
+            var xInPlot = pageX - offset.left;
+
+            if (xInPlot < plot.getPlotOffset().left)
+                return null;
+            if (item)
+                return item;
+
+            var previousPoint;
+            var index = 0;
+            while (1) {
+                var currentPoint = plot.pointOffset({x: points[index * size], y: points[index * size + 1]});
+                if (xInPlot < currentPoint.left) {
+                    if (previousPoint && xInPlot < (previousPoint.left + currentPoint.left) / 2) {
+                        index -= 1;
+                        currentPoint = previousPoint;
+                    }
+                    break;
+                }
+                if (index + 1 >= series.data.length)
+                    break;
+                previousPoint = currentPoint;
+                index++;
+            }
+
+            // Ideally we want to return a real item object but flot doesn't provide an API to obtain one
+            // so create an object that contain properties we use.
+            return {dataIndex: index, pageX: offset.left + currentPoint.left, pageY: offset.top + currentPoint.top};
+        }
+
+        // Return a plot generator. This function is called when we change the number of days to show.
+        this.attach = function () {
+            if (!results)
+                return;
+
+            bounds = computeYAxisBoundsToFitLines(minTime, results, baseline, target);
+
+            var overviewContainer = section.find('.overviewPlot');
+            if (!isDashboard) {
+                overviewPlot = $.plot(overviewContainer, plotData,
+                    $.extend(true, {}, sharedPlotOptions, {
+                        xaxis: {
+                            min: minTime,
+                            // The maximum number of ticks we can fit on the overflow plot is 4.
+                            tickSize: [(TestBuild.now() - minTime) / 4 / 1000, 'second']
+                        },
+                        grid: { hoverable: false, clickable: false },
+                        selection: { mode: "x" },
+                        yaxis: {
+                            show: false,
+                            min: bounds.min,
+                            max: bounds.max,
+                        }
+                }));
+
+                $(plotContainer).bind("plotselected", function (event, ranges) { Chart.zoom(ranges.xaxis.from, ranges.xaxis.to); });
+                $(overviewContainer).bind("plotselected", function (event, ranges) { Chart.zoom(ranges.xaxis.from, ranges.xaxis.to); });
+                $(overviewContainer).bind("plotunselected", function () { Chart.clearZoom(minTime) });
+            }
+
+            if (currentZoom)
+                this.zoom(currentZoom.from, currentZoom.to);
+            else
+                this.attachMainPlot(minTime);
+
+            var self = this;
+
+            // FIXME: Crosshair should stay where it was between charts.
+            $(plotContainer).bind("plothover", function (event, pos, item) {
+                for (var i = 0; i < charts.length; i++) {
+                    if (charts[i] !== self) {
+                        charts[i].setCrosshair(pos);
+                        charts[i].hideTooltip();
+                    }
+                }
+                if (isDashboard)
+                    return;
+                var data = mainPlot.getData();
+                item = closestItemForPageXRespectingPlotOffset(item, mainPlot, data[data.length - 1], pos.pageX);
+                if (!item)
+                    return;
+
+                showTooltipWithResults(tooltip, item.pageX, item.pageY, results.resultAt(item.dataIndex), results.resultAt(item.dataIndex - 1));
+                tooltip.currentItem = item;
+            });
+
+            $(plotContainer).bind("mouseleave", function (event) {
+                var offset = $(plotContainer).offset();
+                if (offset.left <= event.pageX && offset.top <= event.pageY && event.pageX <= offset.left + $(plotContainer).outerWidth()
+                    && event.pageY <= offset.top + $(plotContainer).outerHeight())
+                    return 0;
+                for (var i = 0; i < charts.length; i++) {
+                    charts[i].clearCrosshair();
+                    charts[i].hideTooltip();
+                }
+
+            });
+
+            if (isDashboard) { // FIXME: This code doesn't belong here.
+                $(plotContainer).bind('click', function (event) {
+                    openChart(results.platform(), results.metric());
+                });
+            } else {
+                $(plotContainer).bind("plotclick", function (event, pos, item) {
+                    if (tooltip.currentItem)
+                        toggleClickTooltip(tooltip.currentItem.dataIndex, tooltip.currentItem.pageX, tooltip.currentItem.pageY);
+                });
+            }
+        };
+        
+        charts.push(this);
+    }
+
+    Chart.clear = function () {
+        charts = [];
+    }
+
+    Chart.setMinTime = function (newMinTime) {
+        minTime = newMinTime;
+        for (var i = 0; i < charts.length; i++)
+            charts[i].attach();
+    }
+
+    Chart.onzoomchange = function (from, to) { };
+
+    Chart.zoom = function (from, to) {
+        currentZoom = {from: from, to: to};
+        for (var i = 0; i < charts.length; i++)
+            charts[i].zoom(from, to);
+        this.onzoomchange(from, to);
+    }
+
+    Chart.clearZoom = function (minTime) {
+        currentZoom = undefined;
+        for (var i = 0; i < charts.length; i++)
+            charts[i].clearZoom();
+        this.onzoomchange();
+    }
+
+    window.Chart = Chart;
+})();
+
+// FIXME: We need to devise a way to fetch runs in multiple chunks so that
+// we don't have to fetch the entire time series to just show the last 3 days.
+// FIXME: We should do a mass-fetch where we fetch JSONs for multiple runs at once.
+function fetchTest(repositories, builders, filename, platform, metric, callback) {
+
+    function createRunAndResults(rawRuns) {
+        if (!rawRuns)
+            return null;
+
+        var runs = new PerfTestRuns(metric, platform);
+        var results = rawRuns.map(function (rawRun) {
+            // FIXME: Creating PerfTestResult and keeping them alive in memory all the time seems like a terrible idea.
+            // We should create PerfTestResult on demand.
+            return new PerfTestResult(runs, rawRun, new TestBuild(repositories, builders, platform, rawRun));
+        });
+        sortedResults = results.sort(function (a, b) { return a.build().time() - b.build().time(); });
+        sortedResults.forEach(function (result) { runs.addResult(result); });
+        return runs;
+    }
+
+    $.getJSON('api/runs/' + filename, function (data) {
+        callback(createRunAndResults(data.current), createRunAndResults(data.baseline), createRunAndResults(data.target));
+    });
+}
+
+function init() {
+    var allPlatforms;
+    var tests = [];
+    var fullNameToMetric;
+    var dashboardPlatforms;
+    var runsCache = {}; // FIXME: We need to clear this cache at some point.
+    var repositories;
+    var builders;
+    var bugTrackers;
+
+    // FIXME: Show some error message when we get 404.
+    function getOrFetchTest(platform, metric, callback) {
+        var filename = fileNameFromPlatformAndTest(platform.id, metric.id);
+        var caches = runsCache[filename];
+
+        if (caches)
+            setTimeout(function () { callback(caches.current, caches.baseline, caches.target); }, 0);
+        else {
+            fetchTest(repositories, builders, filename, platform, metric, function (current, baseline, target) {
+                runsCache[filename] = {current:current, baseline:baseline, target:target};
+                callback(current, baseline, target);
+            });
+        }
+    }
+
+    function showDashboard() {
+        var dashboardTable = $('<table id="dashboard"></table>');
+        $('#mainContents').html(dashboardTable);
+
+        // Split dashboard platforms into groups of three so that it doesn't grow horizontally too much.
+        // FIXME: Make this adaptive.
+        for (var i = 0; i < Math.ceil(dashboardPlatforms.length / 3); i++)
+            addPlatformsToDashboard(dashboardTable, dashboardPlatforms.slice(i * 3, (i + 1) * 3));
+
+        URLState.remove('chartList');
+    }
+
+    function addPlatformsToDashboard(dashboardTable, selectedPlatforms) {
+        var header = document.createElement('thead');
+        selectedPlatforms.forEach(function (platform) {
+            var cell = document.createElement('th');
+            $(cell).text(platform.name);
+            header.appendChild(cell);
+        });
+        dashboardTable.append(header);
+
+        var tbody = $(document.createElement('tbody'));
+        dashboardTable.append(tbody);
+        var row = document.createElement('tr');
+        tbody.append(row);
+
+        selectedPlatforms.forEach(function (platform) {
+            var cell = document.createElement('td');
+            row.appendChild(cell);
+
+            platform.metrics.forEach(function (metric) {
+                var chart = new Chart(cell, true, platform, metric, bugTrackers);
+                getOrFetchTest(platform, metric, function (results, baseline, target) {
+                    // FIXME: We shouldn't rely on the order in which XHR finishes to order plots.
+                    if (dashboardTable.parent().length) {
+                        chart.populate(results, baseline, target);
+                        chart.attach();
+                    }
+                });
+            });
+        });
+    }
+
+    function showCharts(lists) {
+        var chartsContainer = document.createElement('section');
+        chartsContainer.id = 'charts';
+        $('#mainContents').html(chartsContainer);
+
+        var testPicker = document.createElement('section');
+        testPicker.id = 'testPicker';
+
+        function addOption(select, label, value) {
+            var option = document.createElement('option');
+            option.appendChild(document.createTextNode(label));
+            if (value)
+                option.value = value;
+            select.appendChild(option);
+        }
+
+        var testList = document.createElement('select');
+        testList.id = 'testList';
+        testPicker.appendChild(testList);
+        for (var i = 0; i < tests.length; ++i) {
+            if (tests[i].parentTest)
+                continue;
+            addOption(testList, tests[i].fullName, tests[i].id);
+        }
+
+        var metricList = document.createElement('select');
+        metricList.id = 'metricList';
+        testPicker.appendChild(metricList);
+
+        var platformList = document.createElement('select');
+        platformList.id = 'platformList';
+        testPicker.appendChild(platformList);
+        const OPTION_VALUE_FOR_ALL = '-';
+        addOption(platformList, 'All platforms', OPTION_VALUE_FOR_ALL);
+        for (var i = 0; i < allPlatforms.length; ++i)
+            addOption(platformList, allPlatforms[i].name);
+
+        testList.onchange = function () {
+            while (metricList.firstChild)
+                metricList.removeChild(metricList.firstChild);
+
+            addOption(metricList, 'All metrics', OPTION_VALUE_FOR_ALL);
+            for (var i = 0; i < tests.length; ++i) {
+                if (tests[i].id != testList.value && (!tests[i].parentTest || tests[i].parentTest.id != testList.value))
+                    continue;
+                var selectedTest = tests[i].id == testList.value ? tests[i] : tests[i].parentTest;
+                for (var j = 0; j < tests[i].metrics.length; ++j) {
+                    var fullName = tests[i].metrics[j].fullName;
+                    var relativeName = fullName.replace(selectedTest.fullName, '').replace(/^[:/]/, '');
+                    addOption(metricList, relativeName, fullName);
+                }
+            }
+        }
+        metricList.onchange = function () {
+            var metric = fullNameToMetric[metricList.value];
+            var shouldAddAllMetrics = metricList.value === OPTION_VALUE_FOR_ALL;
+            for (var i = 0; i < platformList.options.length; ++i) {
+                var option = platformList.options[i];
+                if (option.value === OPTION_VALUE_FOR_ALL) // Adding all metrics for all platforms will be too slow.
+                    option.disabled = shouldAddAllMetrics;
+                else {
+                    var platform = nameToPlatform[option.value];
+                    var platformHasMetric = platform.metrics.indexOf(metric) >= 0;
+                    option.disabled = !shouldAddAllMetrics && !platformHasMetric;
+                }
+            }
+        }
+        testList.onchange();
+        metricList.onchange();
+
+        $(testPicker).append(' <a href="">Add Chart</a>');
+
+        function removeChart(chart) {
+            for (var i = 0; i < chartList.length; i++) {
+                if (chartList[i][0] == chart.platform().name && chartList[i][1] == chart.metric().fullName) {
+                    chartList.splice(i, 1);
+                    break;
+                }
+            }
+            URLState.set('chartList', JSON.stringify(chartList));
+        }
+
+        function createChartFromListPair(platformName, metricFullName) {
+            var platform = nameToPlatform[platformName];
+            var metric = fullNameToMetric[metricFullName]
+            var chart = new Chart(chartsContainer, false, platform, metric, bugTrackers, removeChart);
+
+            getOrFetchTest(platform, metric, function (results, baseline, target) {
+                if (!chartsContainer.parentNode)
+                    return;
+                chart.populate(results, baseline, target);
+                chart.attach();
+            });
+        }
+
+        $(testPicker).children('a').bind('click', function (event) {
+            event.preventDefault();
+
+            var newChartList = [];
+            if (platformList.value === OPTION_VALUE_FOR_ALL) {
+                for (var i = 0; i < allPlatforms.length; ++i) {
+                    createChartFromListPair(allPlatforms[i].name, metricList.value);
+                    newChartList.push([allPlatforms[i].name, metricList.value]);
+                }
+            } else if (metricList.value === OPTION_VALUE_FOR_ALL) {
+                for (var i = 0; i < tests.length; ++i) {
+                    if (tests[i].id != testList.value && (!tests[i].parentTest || tests[i].parentTest.id != testList.value))
+                        continue;
+                    for (var j = 0; j < tests[i].metrics.length; ++j) {
+                        createChartFromListPair(platformList.value, tests[i].metrics[j].fullName);
+                        newChartList.push([platformList.value, tests[i].metrics[j].fullName]);
+                    }
+                }
+            } else {
+                createChartFromListPair(platformList.value, metricList.value);
+                newChartList.push([platformList.value, metricList.value]);
+            }
+
+            chartList = chartList.concat(newChartList);
+            URLState.set('chartList', JSON.stringify(chartList));
+
+            return false;
+        });
+
+        $('#mainContents').append(testPicker);
+
+        var chartList = [];
+        try {
+            chartList = JSON.parse(URLState.get('chartList', ''));
+            // FIXME: Should we verify that platform and test names are valid here?
+        } catch (exception) {
+            // Ignore any exception thrown by parse.
+        }
+
+        chartList.forEach(function (item) { createChartFromListPair(item[0], item[1]); });
+    }
+
+    // FIXME: We should use exponential slider for charts page where we expect to have
+    // the full JSON as opposed to the dashboard where we can't afford loading really big JSON files.
+    var exponential = true;
+    (function () {
+        var input = $('#numberOfDays')[0];
+        var updating = 0;
+        function updateSpanAndCall(newNumberOfDays) {
+            $('#numberOfDays').next().text(newNumberOfDays + ' days');
+            // FIXME: This is likely broken for non-PST.
+            Chart.setMinTime(Date.now() - newNumberOfDays * 24 * 3600 * 1000);
+        }
+        $('#numberOfDays').bind('change', function () {
+            var newNumberOfDays = Math.round(exponential ? Math.exp(input.value) : input.value);
+            URLState.remove('zoom');
+            Chart.clearZoom();
+            URLState.set('days', newNumberOfDays);
+            updateSpanAndCall(newNumberOfDays);
+        });
+        function onchange() {
+            var newNumberOfDays = URLState.get('days', Math.round(exponential ? Math.exp(input.defaultValue) : input.defaultValue));
+            $('#numberOfDays').val(exponential ? Math.log(newNumberOfDays) : newNumberOfDays);
+            updateSpanAndCall(newNumberOfDays);
+        }
+        URLState.watch('days', onchange);
+        onchange();
+    })();
+
+    URLState.watch('mode', function () { setMode(URLState.get('mode')); });
+    URLState.watch('chartList', function (changedStates) {
+        var modeChanged = changedStates.indexOf('mode') >= 0;
+        if (URLState.get('mode') == 'charts' && !modeChanged)
+            setMode('charts');
+    });
+
+    function zoomChartsIfParsedCorrectly() {
+        try {
+            zoomValues = JSON.parse(URLState.get('zoom', '[]'));
+            if (zoomValues.length != 2)
+                return false;
+            Chart.zoom(parseFloat(zoomValues[0]), parseFloat(zoomValues[1]));
+        } catch (exception) {
+            // Ignore all exceptions thrown by JSON.parse.
+        }
+        return true;
+    }
+
+    URLState.watch('zoom', function (changedStates) {
+        var modeChanged = changedStates.indexOf('mode') >= 0;
+        if (URLState.get('mode') == 'charts' && !modeChanged) {
+            if (!zoomChartsIfParsedCorrectly())
+                return Chart.clearZoom();
+        }
+    });
+
+    Chart.onzoomchange = function (from, to) {
+        if (from && to)
+            URLState.set('zoom', JSON.stringify([from, to]));
+        else
+            URLState.remove('zoom');
+    }
+
+    window.openChart = function (platform, metric) {
+        URLState.set('chartList', JSON.stringify([[platform.name, metric.fullName]]));
+        setMode('charts');
+    }
+
+    window.setMode = function (newMode) {
+        URLState.set('mode', newMode);
+
+        Chart.clear();
+        if (newMode == 'dashboard') {
+            URLState.remove('zoom');
+            Chart.clearZoom();
+            showDashboard();
+        } else { // FIXME: Dynamically obtain the list of tests to show.
+            showCharts();
+            zoomChartsIfParsedCorrectly();
+        }
+    }
+
+    function fullName(test) {
+        var names = [];
+        do {
+            names.push(test.name);
+            test = test.parentTest;
+        } while (test);
+        names.reverse();
+        return names.join('/');
+    }
+
+    // Perhaps we want an ordered list of platforms.
+    $.getJSON('data/manifest.json', function (data) {
+        var manifest = data;
+
+        nameToTest = {};
+        for (var testId in manifest.tests) {
+            var test = manifest.tests[testId];
+            test.parentTest = manifest.tests[manifest.tests[testId].parentId];
+            test.id = testId;
+            test.metrics = [];
+            tests.push(test);
+        }
+        tests.forEach(function (test) {
+            test.fullName = fullName(test);
+            nameToTest[test.fullName] = test;
+        });
+        tests.sort(function (a, b) {
+            if (a.fullName < b.fullName)
+                return -1;
+            if (a.fullName > b.fullName)
+                return 1;
+            return 0;
+        });
+
+        fullNameToMetric = {};
+        for (var metricId in manifest.metrics) {
+            var entry = manifest.metrics[metricId];
+            entry.id = metricId;
+            entry.test = manifest.tests[entry.test];
+            entry.fullName = entry.test.fullName + ':' + entry.name;
+            if (entry.aggregator)
+                entry.fullName += ':' + entry.aggregator;
+            entry.test.metrics.push(entry);
+            fullNameToMetric[entry.fullName] = entry;
+        }
+
+        tests.forEach(function (test) {
+            test.metrics.sort(function (a, b) {
+                if (a.name < b.name)
+                    return -1;
+                if (a.name > b.name)
+                    return 1;
+                return 0;
+            });
+        });
+
+        allPlatforms = [];
+        nameToPlatform = {};
+        for (var platformId in manifest.all) {
+            var platform = manifest.all[platformId];
+            platform.id = platformId;
+            allPlatforms.push(platform);
+            nameToPlatform[platform.name] = platform;
+            // FIXME: Sort tests
+            for (var i = 0; i < platform.metrics.length; ++i)
+                platform.metrics[i] = manifest.metrics[platform.metrics[i]];
+        }
+        allPlatforms.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
+
+        dashboardPlatforms = [];
+        for (var platformId in manifest.dashboard) {
+            var platform = manifest.dashboard[platformId];
+            platform.id = platformId;
+            for (var i = 0; i < platform.metrics.length; ++i)
+                platform.metrics[i] = manifest.metrics[platform.metrics[i]];
+            platform.metrics.sort(function (a, b) { return a.fullName < b.fullName ? -1 : (a.fullName > b.fullName ? 1 : 0); });
+            dashboardPlatforms.push(manifest.dashboard[platformId]);
+        }
+        dashboardPlatforms.sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
+
+        repositories = manifest.repositories;
+        builders = manifest.builders;
+        bugTrackers = manifest.bugTrackers;
+
+        setMode(URLState.get('mode', 'dashboard'));
+    });
+}
+
+window.addEventListener('DOMContentLoaded', init, false);
+
+</script>
+</head>
+<body>
+
+<header id="title">
+<h1><a href="/">WebKit Perf Monitor</a></h1>
+<ul>
+    <li><a href="javascript:setMode('dashboard');">Dashboard</a></li>
+    <li><a href="javascript:setMode('charts');">Charts</a></li>
+</ul>
+</header>
+
+<div id="numberOfDaysPicker"><input id="numberOfDays" type="range" min="1" max="5.9" step="0.001" value="2.3"><span class="output"></span></div>
+
+<div id="mainContents"></div>
+</body>
+</html>
diff --git a/Websites/perf.webkit.org/public/js/helper-classes.js b/Websites/perf.webkit.org/public/js/helper-classes.js
new file mode 100755 (executable)
index 0000000..8789ff4
--- /dev/null
@@ -0,0 +1,395 @@
+
+// A point in a plot.
+function PerfTestResult(runs, result, associatedBuild) {
+    this.metric = function () { return runs.metric(); }
+    this.values = function () { return result.values ? result.values.map(function (value) { return runs.scalingFactor() * value; }) : undefined; }
+    this.mean = function () { return runs.scalingFactor() * result.mean; }
+    this.unscaledMean = function () { return result.mean; }
+    this.confidenceIntervalDelta = function () {
+        return runs.scalingFactor() * this.unscaledConfidenceIntervalDelta();
+    }
+    this.unscaledConfidenceIntervalDelta = function () {
+        return Statistics.confidenceIntervalDelta(0.95, result.iterationCount, result.sum, result.squareSum);
+    }
+    this.isBetterThan = function(other) { return runs.smallerIsBetter() == (this.mean() < other.mean()); }
+    this.relativeDifference = function(other) { return (other.mean() - this.mean()) / this.mean(); }
+    this.formattedRelativeDifference = function (other) { return Math.abs(this.relativeDifference(other) * 100).toFixed(2) + '%'; }
+    this.formattedProgressionOrRegression = function (previousResult) {
+        return previousResult.formattedRelativeDifference(this) + ' ' + (this.isBetterThan(previousResult) ? 'better' : 'worse');
+    }
+    this.isStatisticallySignificant = function (other) {
+        var diff = Math.abs(other.mean() - this.mean());
+        return diff > this.confidenceIntervalDelta() && diff > other.confidenceIntervalDelta();
+    }
+    this.build = function () { return associatedBuild; }
+    this.label = function (previousResult) {
+        var mean = this.mean();
+        var label = mean.toPrecision(4) + ' ' + runs.unit();
+
+        var delta = this.confidenceIntervalDelta();
+        if (delta) {
+            var percentageStdev = delta * 100 / mean;
+            label += ' &plusmn; ' + percentageStdev.toFixed(2) + '%';
+        }
+
+        if (previousResult)
+            label += ' (' + this.formattedProgressionOrRegression(previousResult) + ')';
+
+        return label;
+    }
+}
+
+function TestBuild(repositories, builders, platform, rawRun) {
+    const revisions = rawRun.revisions;
+    var maxTime = 0;
+    var revisionCount = 0;
+    for (var repositoryName in revisions) {
+        maxTime = Math.max(maxTime, revisions[repositoryName][1]); // Revision is an pair (revision, time)
+        revisionCount++;
+    }
+    if (!maxTime)
+        maxTime = rawRun.buildTime;
+    maxTime = TestBuild.UTCtoPST(maxTime);
+    var maxTimeString = new Date(maxTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
+    var buildTime = TestBuild.UTCtoPST(rawRun.buildTime);
+    var buildTimeString = new Date(buildTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
+
+    this.time = function () { return maxTime; }
+    this.formattedTime = function () { return maxTimeString; }
+    this.buildTime = function () { return buildTime; }
+    this.formattedBuildTime = function () { return buildTimeString; }
+    this.builder = function () { return builders[rawRun.builder].name; }
+    this.buildNumber = function () { return rawRun.buildNumber; }
+    this.buildUrl = function () {
+        var template = builders[rawRun.builder].buildUrl;
+        return template ? template.replace(/\$buildNumber/g, this.buildNumber()) : null;
+    }
+    this.platform = function () { return platform; }
+    this.revision = function(repositoryName) { return revisions[repositoryName][0]; }
+    this.formattedRevisions = function (previousBuild) {
+        var result = {};
+        for (var repositoryName in revisions) {
+            var previousRevision = previousBuild ? previousBuild.revision(repositoryName) : undefined;
+            var currentRevision = this.revision(repositoryName);
+            if (previousRevision === currentRevision)
+                previousRevision = undefined;
+
+            var revisionPrefix = '';
+            if (currentRevision.length < 10) { // SVN-like revision.
+                revisionPrefix = 'r';
+                if (previousRevision)
+                    previousRevision = (parseInt(previousRevision) + 1);
+            }
+
+            var labelForThisRepository = revisionCount ? repositoryName : '';
+            if (previousRevision) {
+                if (labelForThisRepository)
+                    labelForThisRepository += ' ';
+                labelForThisRepository += revisionPrefix + previousRevision + '-' + revisionPrefix + currentRevision;
+            } else
+                labelForThisRepository += ' @ ' + revisionPrefix + currentRevision;
+
+            var url;
+            var repository = repositories[repositoryName];
+            if (repository) {
+                if (previousRevision)
+                    url = (repository['blameUrl'] || '').replace(/\$1/g, previousRevision).replace(/\$2/g, currentRevision);
+                else
+                    url = (repository['url'] || '').replace(/\$1/g, currentRevision);
+            }
+
+            result[repositoryName] = {
+                'label': labelForThisRepository,
+                'currentRevision': currentRevision,
+                'previousRevision': previousRevision,
+                'url': url,
+            };
+        }
+        return result;
+    }
+}
+
+TestBuild.UTCtoPST = function (date) {
+    // Pretend that PST is UTC since vanilla flot doesn't support multiple timezones.
+    const PSTOffsetInMilliseconds = 8 * 3600 * 1000;
+    return date - PSTOffsetInMilliseconds;
+}
+TestBuild.now = function () { return this.UTCtoPST(Date.now()); }
+
+// A sequence of test results for a specific test on a specific platform
+function PerfTestRuns(metric, platform) {
+    var results = [];
+    var cachedUnit = null;
+    var cachedScalingFactor = null;
+    var baselines = {};
+    var unit = {'Combined': '', // Assume smaller is better for now.
+        'FrameRate': 'fps',
+        'Runs': 'runs/s',
+        'Time': 'ms',
+        'Malloc': 'bytes',
+        'JSHeap': 'bytes',
+        'Allocations': 'bytes',
+        'EndAllocations': 'bytes',
+        'MaxAllocations': 'bytes',
+        'MeanAllocations': 'bytes'}[metric.name];
+
+    // We can't do this in PerfTestResult because all results for each metric need to share the same unit and the same scaling factor.
+    function computeScalingFactorIfNeeded() {
+        // FIXME: We shouldn't be adjusting units on every test result.
+        // We can only do this on the first test.
+        if (!results.length || cachedUnit)
+            return;
+
+        var mean = results[0].unscaledMean(); // FIXME: We should look at all values.
+        var kilo = unit == 'bytes' ? 1024 : 1000;
+        if (mean > 2 * kilo * kilo && unit != 'ms') {
+            cachedScalingFactor = 1 / kilo / kilo;
+            cachedUnit = 'M ' + unit;
+        } else if (mean > 2 * kilo) {
+            cachedScalingFactor = 1 / kilo;
+            cachedUnit = unit == 'ms' ? 's' : ('K ' + unit);
+        } else {
+            cachedScalingFactor = 1;
+            cachedUnit = unit;
+        }
+    }
+
+    this.metric = function () { return metric; }
+    this.platform = function () { return platform; }
+    this.addResult = function (newResult) {
+        if (results.indexOf(newResult) >= 0)
+            return;
+        results.push(newResult);
+        cachedUnit = null;
+        cachedScalingFactor = null;
+    }
+    this.lastResult = function () { return results[results.length - 1]; }
+    this.resultAt = function (i) { return results[i]; }
+
+    var unscaledMeansCache;
+    var unscaledMeansCacheMinTime;
+    function unscaledMeansForAllResults(minTime) {
+        if (unscaledMeansCacheMinTime == minTime && unscaledMeansCache)
+            return unscaledMeansCache;
+        unscaledMeansCache = results.filter(function (result) { return !minTime || result.build().time() >= minTime; })
+            .map(function (result) { return result.unscaledMean(); });
+        unscaledMeansCacheMinTime = minTime;
+        return unscaledMeansCache;
+    }
+
+    this.min = function (minTime) {
+        return this.scalingFactor() * unscaledMeansForAllResults(minTime)
+            .reduce(function (minSoFar, currentMean) { return Math.min(minSoFar, currentMean); }, Number.MAX_VALUE);
+    }
+    this.max = function (minTime, baselineName) {
+        return this.scalingFactor() * unscaledMeansForAllResults(minTime)
+            .reduce(function (maxSoFar, currentMean) { return Math.max(maxSoFar, currentMean); }, Number.MIN_VALUE);
+    }
+    this.sampleStandardDeviation = function (minTime) {
+        var unscaledMeans = unscaledMeansForAllResults(minTime);
+        return this.scalingFactor() * Statistics.sampleStandardDeviation(unscaledMeans.length, Statistics.sum(unscaledMeans), Statistics.squareSum(unscaledMeans));
+    }
+    this.exponentialMovingArithmeticMean = function (minTime, alpha) {
+        var unscaledMeans = unscaledMeansForAllResults(minTime);
+        if (!unscaledMeans.length)
+            return NaN;
+        return this.scalingFactor() * unscaledMeans.reduce(function (movingAverage, currentMean) { return alpha * movingAverage + (1 - alpha) * movingAverage; });
+    }
+    this.hasConfidenceInterval = function () { return !isNaN(this.lastResult().unscaledConfidenceIntervalDelta()); }
+    var meanPlotCache;
+    this.meanPlotData = function () {
+        if (!meanPlotCache)
+            meanPlotCache = results.map(function (result, index) { return [result.build().time(), result.mean()]; });
+        return meanPlotCache;
+    }
+    var upperConfidenceCache;
+    this.upperConfidencePlotData = function () {
+        if (!upperConfidenceCache) // FIXME: Use the actual confidence interval
+            upperConfidenceCache = results.map(function (result, index) { return [result.build().time(), result.mean() + result.confidenceIntervalDelta()]; });
+        return upperConfidenceCache;
+    }
+    var lowerConfidenceCache;
+    this.lowerConfidencePlotData = function () {
+        if (!lowerConfidenceCache) // FIXME: Use the actual confidence interval
+            lowerConfidenceCache = results.map(function (result, index) { return [result.build().time(), result.mean() - result.confidenceIntervalDelta()]; });
+        return lowerConfidenceCache;
+    }
+    this.scalingFactor = function() {
+        computeScalingFactorIfNeeded();
+        return cachedScalingFactor;
+    }
+    this.unit = function () {
+        computeScalingFactorIfNeeded();
+        return cachedUnit;
+    }
+    this.smallerIsBetter = function() { return unit == 'ms' || unit == 'bytes' || unit == ''; }
+}
+
+var URLState = new (function () {
+    var hash;
+    var parameters;
+    var updateTimer;
+
+    function parseIfNeeded() {
+        if (updateTimer || '#' + hash == location.hash)
+            return;
+        hash = location.hash.substr(1).replace(/\/+$/, '');
+        parameters = {};
+        hash.split(/[&;]/).forEach(function (token) {
+            var keyValue = token.split('=');
+            var key = decodeURIComponent(keyValue[0]);
+            if (key.length)
+                parameters[key] = decodeURIComponent(keyValue.slice(1).join('='));
+        });
+    }
+
+    function updateHash() {
+        if (location.hash != hash)
+            location.hash = hash;
+    }
+
+    this.get = function (key, defaultValue) {
+        parseIfNeeded();
+        if (key in parameters)
+            return parameters[key];
+        else
+            return defaultValue;
+    }
+
+    function scheduleHashUpdate() {
+        var newHash = '';
+        for (key in parameters) {
+            if (newHash.length)
+                newHash += '&';
+            newHash += encodeURIComponent(key) + '=' + encodeURIComponent(parameters[key]);
+        }
+        hash = newHash;
+        if (updateTimer)
+            clearTimeout(updateTimer);
+
+        updateTimer = setTimeout(function () {
+            updateTimer = undefined;
+            updateHash();
+        }, 500);
+    }
+
+    this.set = function (key, value) {
+        parseIfNeeded();
+        parameters[key] = value;
+        scheduleHashUpdate();
+    }
+
+    this.remove = function (key) {
+        parseIfNeeded();
+        delete parameters[key];
+        scheduleHashUpdate();
+    }
+
+    var watchCallbacks = {};
+    function onhashchange() {
+        if ('#' + hash == location.hash)
+            return;
+
+        // Race. If the hash had changed while we're waiting to update, ignore the change.
+        if (updateTimer) {
+            clearTimeout(updateTimer);
+            updateTimer = undefined;
+        }
+
+        // FIXME: Consider ignoring URLState.set/remove while notifying callbacks.
+        var oldParameters = parameters;
+        parseIfNeeded();
+        var callbacks = [];
+        var changedStates = [];
+        for (var key in watchCallbacks) {
+            if (parameters[key] == oldParameters[key])
+                continue;
+            changedStates.push(key);
+            callbacks.push(watchCallbacks[key]);
+        }
+
+        for (var i = 0; i < callbacks.length; i++)
+            callbacks[i](changedStates);
+    }
+    $(window).bind('hashchange', onhashchange);
+
+    // FIXME: Support multiple callbacks on a single key.
+    this.watch = function (key, callback) {
+        parseIfNeeded();
+        watchCallbacks[key] = callback;
+    }
+});
+
+function Tooltip(containerParent, className) {
+    var container;
+    var self = this;
+    var hideTimer; // Use setTimeout(~, 0) to workaround the race condition that arises when moving mouse fast.
+    var previousContent;
+
+    function ensureContainer() {
+        if (container)
+            return;
+        container = document.createElement('div');
+        $(containerParent).append(container);
+        container.className = className;
+        container.style.position = 'absolute';
+        $(container).hide();
+    }
+
+    this.show = function (x, y, content) {
+        if (hideTimer) {
+            clearTimeout(hideTimer);
+            hideTimer = undefined;
+        }
+
+        if (previousContent === content) {
+            $(container).show();
+            return;
+        }
+        previousContent = content;
+
+        ensureContainer();
+        container.innerHTML = content;
+        $(container).show();
+        // FIXME: Style specific computation like this one shouldn't be in Tooltip class.
+        $(container).offset({left: x - $(container).outerWidth() / 2, top: y - $(container).outerHeight() - 15});
+    }
+
+    this.hide = function () {
+        if (!container)
+            return;
+
+        if (hideTimer)
+            clearTimeout(hideTimer);
+        hideTimer = setTimeout(function () {
+            $(container).fadeOut(100);
+            previousResult = undefined;
+        }, 0);
+    }
+
+    this.remove = function (immediately) {
+        if (!container)
+            return;
+
+        if (hideTimer)
+            clearTimeout(hideTimer);
+
+        $(container).remove();
+        container = undefined;
+        previousResult = undefined;
+    }
+
+    this.toggle = function () {
+        $(container).toggle();
+        document.body.appendChild(container); // Toggled tooltip should show up at the top.
+    }
+
+    this.bindClick = function (callback) {
+        ensureContainer();
+        $(container).bind('click', callback);
+    }
+    this.bindMouseEnter = function (callback) {
+        ensureContainer();
+        $(container).bind('mouseenter', callback);
+    }
+}
diff --git a/Websites/perf.webkit.org/public/js/jquery.colorhelpers.js b/Websites/perf.webkit.org/public/js/jquery.colorhelpers.js
new file mode 100644 (file)
index 0000000..d3524d7
--- /dev/null
@@ -0,0 +1,179 @@
+/* Plugin for jQuery for working with colors.
+ * 
+ * Version 1.1.
+ * 
+ * Inspiration from jQuery color animation plugin by John Resig.
+ *
+ * Released under the MIT license by Ole Laursen, October 2009.
+ *
+ * Examples:
+ *
+ *   $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString()
+ *   var c = $.color.extract($("#mydiv"), 'background-color');
+ *   console.log(c.r, c.g, c.b, c.a);
+ *   $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)"
+ *
+ * Note that .scale() and .add() return the same modified object
+ * instead of making a new one.
+ *
+ * V. 1.1: Fix error handling so e.g. parsing an empty string does
+ * produce a color rather than just crashing.
+ */ 
+
+(function($) {
+    $.color = {};
+
+    // construct color object with some convenient chainable helpers
+    $.color.make = function (r, g, b, a) {
+        var o = {};
+        o.r = r || 0;
+        o.g = g || 0;
+        o.b = b || 0;
+        o.a = a != null ? a : 1;
+
+        o.add = function (c, d) {
+            for (var i = 0; i < c.length; ++i)
+                o[c.charAt(i)] += d;
+            return o.normalize();
+        };
+        
+        o.scale = function (c, f) {
+            for (var i = 0; i < c.length; ++i)
+                o[c.charAt(i)] *= f;
+            return o.normalize();
+        };
+        
+        o.toString = function () {
+            if (o.a >= 1.0) {
+                return "rgb("+[o.r, o.g, o.b].join(",")+")";
+            } else {
+                return "rgba("+[o.r, o.g, o.b, o.a].join(",")+")";
+            }
+        };
+
+        o.normalize = function () {
+            function clamp(min, value, max) {
+                return value < min ? min: (value > max ? max: value);
+            }
+            
+            o.r = clamp(0, parseInt(o.r), 255);
+            o.g = clamp(0, parseInt(o.g), 255);
+            o.b = clamp(0, parseInt(o.b), 255);
+            o.a = clamp(0, o.a, 1);
+            return o;
+        };
+
+        o.clone = function () {
+            return $.color.make(o.r, o.b, o.g, o.a);
+        };
+
+        return o.normalize();
+    }
+
+    // extract CSS color property from element, going up in the DOM
+    // if it's "transparent"
+    $.color.extract = function (elem, css) {
+        var c;
+        do {
+            c = elem.css(css).toLowerCase();
+            // keep going until we find an element that has color, or
+            // we hit the body
+            if (c != '' && c != 'transparent')
+                break;
+            elem = elem.parent();
+        } while (!$.nodeName(elem.get(0), "body"));
+
+        // catch Safari's way of signalling transparent
+        if (c == "rgba(0, 0, 0, 0)")
+            c = "transparent";
+        
+        return $.color.parse(c);
+    }
+    
+    // parse CSS color string (like "rgb(10, 32, 43)" or "#fff"),
+    // returns color object, if parsing failed, you get black (0, 0,
+    // 0) out
+    $.color.parse = function (str) {
+        var res, m = $.color.make;
+
+        // Look for rgb(num,num,num)
+        if (res = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))
+            return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10));
+        
+        // Look for rgba(num,num,num,num)
+        if (res = /rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))
+            return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10), parseFloat(res[4]));
+            
+        // Look for rgb(num%,num%,num%)
+        if (res = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))
+            return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55);
+
+        // Look for rgba(num%,num%,num%,num)
+        if (res = /rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))
+            return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55, parseFloat(res[4]));
+        
+        // Look for #a0b1c2
+        if (res = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))
+            return m(parseInt(res[1], 16), parseInt(res[2], 16), parseInt(res[3], 16));
+
+        // Look for #fff
+        if (res = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))
+            return m(parseInt(res[1]+res[1], 16), parseInt(res[2]+res[2], 16), parseInt(res[3]+res[3], 16));
+
+        // Otherwise, we're most likely dealing with a named color
+        var name = $.trim(str).toLowerCase();
+        if (name == "transparent")
+            return m(255, 255, 255, 0);
+        else {
+            // default to black
+            res = lookupColors[name] || [0, 0, 0];
+            return m(res[0], res[1], res[2]);
+        }
+    }
+    
+    var lookupColors = {
+        aqua:[0,255,255],
+        azure:[240,255,255],
+        beige:[245,245,220],
+        black:[0,0,0],
+        blue:[0,0,255],
+        brown:[165,42,42],
+        cyan:[0,255,255],
+        darkblue:[0,0,139],
+        darkcyan:[0,139,139],
+        darkgrey:[169,169,169],
+        darkgreen:[0,100,0],
+        darkkhaki:[189,183,107],
+        darkmagenta:[139,0,139],
+        darkolivegreen:[85,107,47],
+        darkorange:[255,140,0],
+        darkorchid:[153,50,204],
+        darkred:[139,0,0],
+        darksalmon:[233,150,122],
+        darkviolet:[148,0,211],
+        fuchsia:[255,0,255],
+        gold:[255,215,0],
+        green:[0,128,0],
+        indigo:[75,0,130],
+        khaki:[240,230,140],
+        lightblue:[173,216,230],
+        lightcyan:[224,255,255],
+        lightgreen:[144,238,144],
+        lightgrey:[211,211,211],
+        lightpink:[255,182,193],
+        lightyellow:[255,255,224],
+        lime:[0,255,0],
+        magenta:[255,0,255],
+        maroon:[128,0,0],
+        navy:[0,0,128],
+        olive:[128,128,0],
+        orange:[255,165,0],
+        pink:[255,192,203],
+        purple:[128,0,128],
+        violet:[128,0,128],
+        red:[255,0,0],
+        silver:[192,192,192],
+        white:[255,255,255],
+        yellow:[255,255,0]
+    };
+})(jQuery);
diff --git a/Websites/perf.webkit.org/public/js/jquery.flot.categories.js b/Websites/perf.webkit.org/public/js/jquery.flot.categories.js
new file mode 100644 (file)
index 0000000..dd07768
--- /dev/null
@@ -0,0 +1,190 @@
+/* Flot plugin for plotting textual data or categories.
+
+Copyright (c) 2007-2012 IOLA and Ole Laursen.
+Licensed under the MIT license.
+
+Consider a dataset like [["February", 34], ["March", 20], ...]. This plugin
+allows you to plot such a dataset directly.
+
+To enable it, you must specify mode: "categories" on the axis with the textual
+labels, e.g.
+
+    $.plot("#placeholder", data, { xaxis: { mode: "categories" } });
+
+By default, the labels are ordered as they are met in the data series. If you
+need a different ordering, you can specify "categories" on the axis options
+and list the categories there:
+
+    xaxis: {
+        mode: "categories",
+        categories: ["February", "March", "April"]
+    }
+
+If you need to customize the distances between the categories, you can specify
+"categories" as an object mapping labels to values
+
+    xaxis: {
+        mode: "categories",
+        categories: { "February": 1, "March": 3, "April": 4 }
+    }
+
+If you don't specify all categories, the remaining categories will be numbered
+from the max value plus 1 (with a spacing of 1 between each).
+
+Internally, the plugin works by transforming the input data through an auto-
+generated mapping where the first category becomes 0, the second 1, etc.
+Hence, a point like ["February", 34] becomes [0, 34] internally in Flot (this
+is visible in hover and click events that return numbers rather than the
+category labels). The plugin also overrides the tick generator to spit out the
+categories as ticks instead of the values.
+
+If you need to map a value back to its label, the mapping is always accessible
+as "categories" on the axis object, e.g. plot.getAxes().xaxis.categories.
+
+*/
+
+(function ($) {
+    var options = {
+        xaxis: {
+            categories: null
+        },
+        yaxis: {
+            categories: null
+        }
+    };
+    
+    function processRawData(plot, series, data, datapoints) {
+        // if categories are enabled, we need to disable
+        // auto-transformation to numbers so the strings are intact
+        // for later processing
+
+        var xCategories = series.xaxis.options.mode == "categories",
+            yCategories = series.yaxis.options.mode == "categories";
+        
+        if (!(xCategories || yCategories))
+            return;
+
+        var format = datapoints.format;
+
+        if (!format) {
+            // FIXME: auto-detection should really not be defined here
+            var s = series;
+            format = [];
+            format.push({ x: true, number: true, required: true });
+            format.push({ y: true, number: true, required: true });
+
+            if (s.bars.show || (s.lines.show && s.lines.fill)) {
+                var autoscale = !!((s.bars.show && s.bars.zero) || (s.lines.show && s.lines.zero));
+                format.push({ y: true, number: true, required: false, defaultValue: 0, autoscale: autoscale });
+                if (s.bars.horizontal) {
+                    delete format[format.length - 1].y;
+                    format[format.length - 1].x = true;
+                }
+            }
+            
+            datapoints.format = format;
+        }
+
+        for (var m = 0; m < format.length; ++m) {
+            if (format[m].x && xCategories)
+                format[m].number = false;
+            
+            if (format[m].y && yCategories)
+                format[m].number = false;
+        }
+    }
+
+    function getNextIndex(categories) {
+        var index = -1;
+        
+        for (var v in categories)
+            if (categories[v] > index)
+                index = categories[v];
+
+        return index + 1;
+    }
+
+    function categoriesTickGenerator(axis) {
+        var res = [];
+        for (var label in axis.categories) {
+            var v = axis.categories[label];
+            if (v >= axis.min && v <= axis.max)
+                res.push([v, label]);
+        }
+
+        res.sort(function (a, b) { return a[0] - b[0]; });
+
+        return res;
+    }
+    
+    function setupCategoriesForAxis(series, axis, datapoints) {
+        if (series[axis].options.mode != "categories")
+            return;
+        
+        if (!series[axis].categories) {
+            // parse options
+            var c = {}, o = series[axis].options.categories || {};
+            if ($.isArray(o)) {
+                for (var i = 0; i < o.length; ++i)
+                    c[o[i]] = i;
+            }
+            else {
+                for (var v in o)
+                    c[v] = o[v];
+            }
+            
+            series[axis].categories = c;
+        }
+
+        // fix ticks
+        if (!series[axis].options.ticks)
+            series[axis].options.ticks = categoriesTickGenerator;
+
+        transformPointsOnAxis(datapoints, axis, series[axis].categories);
+    }
+    
+    function transformPointsOnAxis(datapoints, axis, categories) {
+        // go through the points, transforming them
+        var points = datapoints.points,
+            ps = datapoints.pointsize,
+            format = datapoints.format,
+            formatColumn = axis.charAt(0),
+            index = getNextIndex(categories);
+
+        for (var i = 0; i < points.length; i += ps) {
+            if (points[i] == null)
+                continue;
+            
+            for (var m = 0; m < ps; ++m) {
+                var val = points[i + m];
+
+                if (val == null || !format[m][formatColumn])
+                    continue;
+
+                if (!(val in categories)) {
+                    categories[val] = index;
+                    ++index;
+                }
+                
+                points[i + m] = categories[val];
+            }
+        }
+    }
+
+    function processDatapoints(plot, series, datapoints) {
+        setupCategoriesForAxis(series, "xaxis", datapoints);
+        setupCategoriesForAxis(series, "yaxis", datapoints);
+    }
+
+    function init(plot) {
+        plot.hooks.processRawData.push(processRawData);
+        plot.hooks.processDatapoints.push(processDatapoints);
+    }
+    
+    $.plot.plugins.push({
+        init: init,
+        options: options,
+        name: 'categories',
+        version: '1.0'
+    });
+})(jQuery);
diff --git a/Websites/perf.webkit.org/public/js/jquery.flot.crosshair.js b/Websites/perf.webkit.org/public/js/jquery.flot.crosshair.js
new file mode 100644 (file)
index 0000000..d8c5716
--- /dev/null
@@ -0,0 +1,176 @@
+/* Flot plugin for showing crosshairs when the mouse hovers over the plot.
+
+Copyright (c) 2007-2012 IOLA and Ole Laursen.
+Licensed under the MIT license.
+
+The plugin supports these options:
+
+    crosshair: {
+        mode: null or "x" or "y" or "xy"
+        color: color
+        lineWidth: number
+    }
+
+Set the mode to one of "x", "y" or "xy". The "x" mode enables a vertical
+crosshair that lets you trace the values on the x axis, "y" enables a
+horizontal crosshair and "xy" enables them both. "color" is the color of the
+crosshair (default is "rgba(170, 0, 0, 0.80)"), "lineWidth" is the width of
+the drawn lines (default is 1).
+
+The plugin also adds four public methods:
+
+  - setCrosshair( pos )
+
+    Set the position of the crosshair. Note that this is cleared if the user
+    moves the mouse. "pos" is in coordinates of the plot and should be on the
+    form { x: xpos, y: ypos } (you can use x2/x3/... if you're using multiple
+    axes), which is coincidentally the same format as what you get from a
+    "plothover" event. If "pos" is null, the crosshair is cleared.
+
+  - clearCrosshair()
+
+    Clear the crosshair.
+
+  - lockCrosshair(pos)
+
+    Cause the crosshair to lock to the current location, no longer updating if
+    the user moves the mouse. Optionally supply a position (passed on to
+    setCrosshair()) to move it to.
+
+    Example usage:
+
+    var myFlot = $.plot( $("#graph"), ..., { crosshair: { mode: "x" } } };
+    $("#graph").bind( "plothover", function ( evt, position, item ) {
+        if ( item ) {
+            // Lock the crosshair to the data point being hovered
+            myFlot.lockCrosshair({
+                x: item.datapoint[ 0 ],
+                y: item.datapoint[ 1 ]
+            });
+        } else {
+            // Return normal crosshair operation
+            myFlot.unlockCrosshair();
+        }
+    });
+
+  - unlockCrosshair()
+
+    Free the crosshair to move again after locking it.
+*/
+
+(function ($) {
+    var options = {
+        crosshair: {
+            mode: null, // one of null, "x", "y" or "xy",
+            color: "rgba(170, 0, 0, 0.80)",
+            lineWidth: 1
+        }
+    };
+    
+    function init(plot) {
+        // position of crosshair in pixels
+        var crosshair = { x: -1, y: -1, locked: false };
+
+        plot.setCrosshair = function setCrosshair(pos) {
+            if (!pos)
+                crosshair.x = -1;
+            else {
+                var o = plot.p2c(pos);
+                crosshair.x = Math.max(0, Math.min(o.left, plot.width()));
+                crosshair.y = Math.max(0, Math.min(o.top, plot.height()));
+            }
+            
+            plot.triggerRedrawOverlay();
+        };
+        
+        plot.clearCrosshair = plot.setCrosshair; // passes null for pos
+        
+        plot.lockCrosshair = function lockCrosshair(pos) {
+            if (pos)
+                plot.setCrosshair(pos);
+            crosshair.locked = true;
+        };
+
+        plot.unlockCrosshair = function unlockCrosshair() {
+            crosshair.locked = false;
+        };
+
+        function onMouseOut(e) {
+            if (crosshair.locked)
+                return;
+
+            if (crosshair.x != -1) {
+                crosshair.x = -1;
+                plot.triggerRedrawOverlay();
+            }
+        }
+
+        function onMouseMove(e) {
+            if (crosshair.locked)
+                return;
+                
+            if (plot.getSelection && plot.getSelection()) {
+                crosshair.x = -1; // hide the crosshair while selecting
+                return;
+            }
+                
+            var offset = plot.offset();
+            crosshair.x = Math.max(0, Math.min(e.pageX - offset.left, plot.width()));
+            crosshair.y = Math.max(0, Math.min(e.pageY - offset.top, plot.height()));
+            plot.triggerRedrawOverlay();
+        }
+        
+        plot.hooks.bindEvents.push(function (plot, eventHolder) {
+            if (!plot.getOptions().crosshair.mode)
+                return;
+
+            eventHolder.mouseout(onMouseOut);
+            eventHolder.mousemove(onMouseMove);
+        });
+
+        plot.hooks.drawOverlay.push(function (plot, ctx) {
+            var c = plot.getOptions().crosshair;
+            if (!c.mode)
+                return;
+
+            var plotOffset = plot.getPlotOffset();
+            
+            ctx.save();
+            ctx.translate(plotOffset.left, plotOffset.top);
+
+            if (crosshair.x != -1) {
+                var adj = plot.getOptions().crosshair.lineWidth % 2 === 0 ? 0 : 0.5;
+
+                ctx.strokeStyle = c.color;
+                ctx.lineWidth = c.lineWidth;
+                ctx.lineJoin = "round";
+
+                ctx.beginPath();
+                if (c.mode.indexOf("x") != -1) {
+                    var drawX = Math.round(crosshair.x) + adj;
+                    ctx.moveTo(drawX, 0);
+                    ctx.lineTo(drawX, plot.height());
+                }
+                if (c.mode.indexOf("y") != -1) {
+                    var drawY = Math.round(crosshair.y) + adj;
+                    ctx.moveTo(0, drawY);
+                    ctx.lineTo(plot.width(), drawY);
+                }
+                ctx.stroke();
+            }
+            ctx.restore();
+        });
+
+        plot.hooks.shutdown.push(function (plot, eventHolder) {
+            eventHolder.unbind("mouseout", onMouseOut);
+            eventHolder.unbind("mousemove", onMouseMove);
+        });
+    }
+    
+    $.plot.plugins.push({
+        init: init,
+        options: options,
+        name: 'crosshair',
+        version: '1.0'
+    });
+})(jQuery);
diff --git a/Websites/perf.webkit.org/public/js/jquery.flot.errorbars.js b/Websites/perf.webkit.org/public/js/jquery.flot.errorbars.js
new file mode 100644 (file)
index 0000000..3f74a0d
--- /dev/null
@@ -0,0 +1,353 @@
+/* Flot plugin for plotting error bars.
+
+Copyright (c) 2007-2012 IOLA and Ole Laursen.
+Licensed under the MIT license.
+
+Error bars are used to show standard deviation and other statistical
+properties in a plot.
+
+* Created by Rui Pereira  -  rui (dot) pereira (at) gmail (dot) com
+
+This plugin allows you to plot error-bars over points. Set "errorbars" inside
+the points series to the axis name over which there will be error values in
+your data array (*even* if you do not intend to plot them later, by setting
+"show: null" on xerr/yerr).
+
+The plugin supports these options:
+
+    series: {
+        points: {
+            errorbars: "x" or "y" or "xy",
+            xerr: {
+                show: null/false or true,
+                asymmetric: null/false or true,
+                upperCap: null or "-" or function,
+                lowerCap: null or "-" or function,
+                color: null or color,
+                radius: null or number
+            },
+            yerr: { same options as xerr }
+        }
+    }
+
+Each data point array is expected to be of the type:
+
+    "x"  [ x, y, xerr ]
+    "y"  [ x, y, yerr ]
+    "xy" [ x, y, xerr, yerr ]
+
+Where xerr becomes xerr_lower,xerr_upper for the asymmetric error case, and
+equivalently for yerr. Eg., a datapoint for the "xy" case with symmetric
+error-bars on X and asymmetric on Y would be:
+
+    [ x, y, xerr, yerr_lower, yerr_upper ]
+
+By default no end caps are drawn. Setting upperCap and/or lowerCap to "-" will
+draw a small cap perpendicular to the error bar. They can also be set to a
+user-defined drawing function, with (ctx, x, y, radius) as parameters, as eg.
+
+    function drawSemiCircle( ctx, x, y, radius ) {
+        ctx.beginPath();
+        ctx.arc( x, y, radius, 0, Math.PI, false );
+        ctx.moveTo( x - radius, y );
+        ctx.lineTo( x + radius, y );
+        ctx.stroke();
+    }
+
+Color and radius both default to the same ones of the points series if not
+set. The independent radius parameter on xerr/yerr is useful for the case when
+we may want to add error-bars to a line, without showing the interconnecting
+points (with radius: 0), and still showing end caps on the error-bars.
+shadowSize and lineWidth are derived as well from the points series.
+
+*/
+
+(function ($) {
+    var options = {
+        series: {
+            points: {
+                errorbars: null, //should be 'x', 'y' or 'xy'
+                xerr: { err: 'x', show: null, asymmetric: null, upperCap: null, lowerCap: null, color: null, radius: null},
+                yerr: { err: 'y', show: null, asymmetric: null, upperCap: null, lowerCap: null, color: null, radius: null}
+            }
+        }
+    };
+
+    function processRawData(plot, series, data, datapoints){
+        if (!series.points.errorbars)
+            return;
+
+        // x,y values
+        var format = [
+            { x: true, number: true, required: true },
+            { y: true, number: true, required: true }
+        ];
+
+        var errors = series.points.errorbars;
+        // error bars - first X then Y
+        if (errors == 'x' || errors == 'xy') {
+            // lower / upper error
+            if (series.points.xerr.asymmetric) {
+                format.push({ x: true, number: true, required: true });
+                format.push({ x: true, number: true, required: true });
+            } else
+                format.push({ x: true, number: true, required: true });
+        }
+        if (errors == 'y' || errors == 'xy') {
+            // lower / upper error
+            if (series.points.yerr.asymmetric) {
+                format.push({ y: true, number: true, required: true });
+                format.push({ y: true, number: true, required: true });
+            } else
+                format.push({ y: true, number: true, required: true });
+        }
+        datapoints.format = format;
+    }
+
+    function parseErrors(series, i){
+
+        var points = series.datapoints.points;
+
+        // read errors from points array
+        var exl = null,
+                exu = null,
+                eyl = null,
+                eyu = null;
+        var xerr = series.points.xerr,
+                yerr = series.points.yerr;
+
+        var eb = series.points.errorbars;
+        // error bars - first X
+        if (eb == 'x' || eb == 'xy') {
+            if (xerr.asymmetric) {
+                exl = points[i + 2];
+                exu = points[i + 3];
+                if (eb == 'xy')
+                    if (yerr.asymmetric){
+                        eyl = points[i + 4];
+                        eyu = points[i + 5];
+                    } else eyl = points[i + 4];
+            } else {
+                exl = points[i + 2];
+                if (eb == 'xy')
+                    if (yerr.asymmetric) {
+                        eyl = points[i + 3];
+                        eyu = points[i + 4];
+                    } else eyl = points[i + 3];
+            }
+        // only Y
+        } else if (eb == 'y')
+            if (yerr.asymmetric) {
+                eyl = points[i + 2];
+                eyu = points[i + 3];
+            } else eyl = points[i + 2];
+
+        // symmetric errors?
+        if (exu == null) exu = exl;
+        if (eyu == null) eyu = eyl;
+
+        var errRanges = [exl, exu, eyl, eyu];
+        // nullify if not showing
+        if (!xerr.show){
+            errRanges[0] = null;
+            errRanges[1] = null;
+        }
+        if (!yerr.show){
+            errRanges[2] = null;
+            errRanges[3] = null;
+        }
+        return errRanges;
+    }
+
+    function drawSeriesErrors(plot, ctx, s){
+
+        var points = s.datapoints.points,
+                ps = s.datapoints.pointsize,
+                ax = [s.xaxis, s.yaxis],
+                radius = s.points.radius,
+                err = [s.points.xerr, s.points.yerr];
+
+        //sanity check, in case some inverted axis hack is applied to flot
+        var invertX = false;
+        if (ax[0].p2c(ax[0].max) < ax[0].p2c(ax[0].min)) {
+            invertX = true;
+            var tmp = err[0].lowerCap;
+            err[0].lowerCap = err[0].upperCap;
+            err[0].upperCap = tmp;
+        }
+
+        var invertY = false;
+        if (ax[1].p2c(ax[1].min) < ax[1].p2c(ax[1].max)) {
+            invertY = true;
+            var tmp = err[1].lowerCap;
+            err[1].lowerCap = err[1].upperCap;
+            err[1].upperCap = tmp;
+        }
+
+        for (var i = 0; i < s.datapoints.points.length; i += ps) {
+
+            //parse
+            var errRanges = parseErrors(s, i);
+
+            //cycle xerr & yerr
+            for (var e = 0; e < err.length; e++){
+
+                var minmax = [ax[e].min, ax[e].max];
+
+                //draw this error?
+                if (errRanges[e * err.length]){
+
+                    //data coordinates
+                    var x = points[i],
+                        y = points[i + 1];
+
+                    //errorbar ranges
+                    var upper = [x, y][e] + errRanges[e * err.length + 1],
+                        lower = [x, y][e] - errRanges[e * err.length];
+
+                    //points outside of the canvas
+                    if (err[e].err == 'x')
+                        if (y > ax[1].max || y < ax[1].min || upper < ax[0].min || lower > ax[0].max)
+                            continue;
+                    if (err[e].err == 'y')
+                        if (x > ax[0].max || x < ax[0].min || upper < ax[1].min || lower > ax[1].max)
+                            continue;
+
+                    // prevent errorbars getting out of the canvas
+                    var drawUpper = true,
+                        drawLower = true;
+
+                    if (upper > minmax[1]) {
+                        drawUpper = false;
+                        upper = minmax[1];
+                    }
+                    if (lower < minmax[0]) {
+                        drawLower = false;
+                        lower = minmax[0];
+                    }
+
+                    //sanity check, in case some inverted axis hack is applied to flot
+                    if ((err[e].err == 'x' && invertX) || (err[e].err == 'y' && invertY)) {
+                        //swap coordinates
+                        var tmp = lower;
+                        lower = upper;
+                        upper = tmp;
+                        tmp = drawLower;
+                        drawLower = drawUpper;
+                        drawUpper = tmp;
+                        tmp = minmax[0];
+                        minmax[0] = minmax[1];
+                        minmax[1] = tmp;
+                    }
+
+                    // convert to pixels
+                    x = ax[0].p2c(x),
+                        y = ax[1].p2c(y),
+                        upper = ax[e].p2c(upper);
+                    lower = ax[e].p2c(lower);
+                    minmax[0] = ax[e].p2c(minmax[0]);
+                    minmax[1] = ax[e].p2c(minmax[1]);
+
+                    //same style as points by default
+                    var lw = err[e].lineWidth ? err[e].lineWidth : s.points.lineWidth,
+                        sw = s.points.shadowSize != null ? s.points.shadowSize : s.shadowSize;
+
+                    //shadow as for points
+                    if (lw > 0 && sw > 0) {
+                        var w = sw / 2;
+                        ctx.lineWidth = w;
+                        ctx.strokeStyle = "rgba(0,0,0,0.1)";
+                        drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, w + w/2, minmax);
+
+                        ctx.strokeStyle = "rgba(0,0,0,0.2)";
+                        drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, w/2, minmax);
+                    }
+
+                    ctx.strokeStyle = err[e].color? err[e].color: s.color;
+                    ctx.lineWidth = lw;
+                    //draw it
+                    drawError(ctx, err[e], x, y, upper, lower, drawUpper, drawLower, radius, 0, minmax);
+                }
+            }
+        }
+    }
+
+    function drawError(ctx,err,x,y,upper,lower,drawUpper,drawLower,radius,offset,minmax){
+
+        //shadow offset
+        y += offset;
+        upper += offset;
+        lower += offset;
+
+        // error bar - avoid plotting over circles
+        if (err.err == 'x'){
+            if (upper > x + radius) drawPath(ctx, [[upper,y],[Math.max(x + radius,minmax[0]),y]]);
+            else drawUpper = false;
+            if (lower < x - radius) drawPath(ctx, [[Math.min(x - radius,minmax[1]),y],[lower,y]] );
+            else drawLower = false;
+        }
+        else {
+            if (upper < y - radius) drawPath(ctx, [[x,upper],[x,Math.min(y - radius,minmax[0])]] );
+            else drawUpper = false;
+            if (lower > y + radius) drawPath(ctx, [[x,Math.max(y + radius,minmax[1])],[x,lower]] );
+            else drawLower = false;
+        }
+
+        //internal radius value in errorbar, allows to plot radius 0 points and still keep proper sized caps
+        //this is a way to get errorbars on lines without visible connecting dots
+        radius = err.radius != null? err.radius: radius;
+
+        // upper cap
+        if (drawUpper) {
+            if (err.upperCap == '-'){
+                if (err.err=='x') drawPath(ctx, [[upper,y - radius],[upper,y + radius]] );
+                else drawPath(ctx, [[x - radius,upper],[x + radius,upper]] );
+            } else if ($.isFunction(err.upperCap)){
+                if (err.err=='x') err.upperCap(ctx, upper, y, radius);
+                else err.upperCap(ctx, x, upper, radius);
+            }
+        }
+        // lower cap
+        if (drawLower) {
+            if (err.lowerCap == '-'){
+                if (err.err=='x') drawPath(ctx, [[lower,y - radius],[lower,y + radius]] );
+                else drawPath(ctx, [[x - radius,lower],[x + radius,lower]] );
+            } else if ($.isFunction(err.lowerCap)){
+                if (err.err=='x') err.lowerCap(ctx, lower, y, radius);
+                else err.lowerCap(ctx, x, lower, radius);
+            }
+        }
+    }
+
+    function drawPath(ctx, pts){
+        ctx.beginPath();
+        ctx.moveTo(pts[0][0], pts[0][1]);
+        for (var p=1; p < pts.length; p++)
+            ctx.lineTo(pts[p][0], pts[p][1]);
+        ctx.stroke();
+    }
+
+    function draw(plot, ctx){
+        var plotOffset = plot.getPlotOffset();
+
+        ctx.save();
+        ctx.translate(plotOffset.left, plotOffset.top);
+        $.each(plot.getData(), function (i, s) {
+            if (s.points.errorbars && (s.points.xerr.show || s.points.yerr.show))
+                drawSeriesErrors(plot, ctx, s);
+        });
+        ctx.restore();
+    }
+
+    function init(plot) {
+        plot.hooks.processRawData.push(processRawData);
+        plot.hooks.draw.push(draw);
+    }
+
+    $.plot.plugins.push({
+                init: init,
+                options: options,
+                name: 'errorbars',
+                version: '1.0'
+            });
+})(jQuery);
diff --git a/Websites/perf.webkit.org/public/js/jquery.flot.fillbetween.js b/Websites/perf.webkit.org/public/js/jquery.flot.fillbetween.js
new file mode 100644 (file)
index 0000000..67cfb4f
--- /dev/null
@@ -0,0 +1,226 @@
+/* Flot plugin for computing bottoms for filled line and bar charts.
+
+Copyright (c) 2007-2012 IOLA and Ole Laursen.
+Licensed under the MIT license.
+
+The case: you've got two series that you want to fill the area between. In Flot
+terms, you need to use one as the fill bottom of the other. You can specify the
+bottom of each data point as the third coordinate manually, or you can use this
+plugin to compute it for you.
+
+In order to name the other series, you need to give it an id, like this:
+
+    var dataset = [
+        { data: [ ... ], id: "foo" } ,         // use default bottom
+        { data: [ ... ], fillBetween: "foo" }, // use first dataset as bottom
+    ];
+
+    $.plot($("#placeholder"), dataset, { lines: { show: true, fill: true }});
+
+As a convenience, if the id given is a number that doesn't appear as an id in
+the series, it is interpreted as the index in the array instead (so fillBetween:
+0 can also mean the first series).
+
+Internally, the plugin modifies the datapoints in each series. For line series,
+extra data points might be inserted through interpolation. Note that at points
+where the bottom line is not defined (due to a null point or start/end of line),
+the current line will show a gap too. The algorithm comes from the
+jquery.flot.stack.js plugin, possibly some code could be shared.
+
+*/
+
+(function ( $ ) {
+
+    var options = {
+        series: {
+            fillBetween: null    // or number
+        }
+    };
+
+    function init( plot ) {
+
+        function findBottomSeries( s, allseries ) {
+
+            var i;
+
+            for ( i = 0; i < allseries.length; ++i ) {
+                if ( allseries[ i ].id === s.fillBetween ) {
+                    return allseries[ i ];
+                }
+            }
+
+            if ( typeof s.fillBetween === "number" ) {
+                if ( s.fillBetween < 0 || s.fillBetween >= allseries.length ) {
+                    return null;
+                }
+                return allseries[ s.fillBetween ];
+            }
+
+            return null;
+        }
+
+        function computeFillBottoms( plot, s, datapoints ) {
+
+            if ( s.fillBetween == null ) {
+                return;
+            }
+
+            var other = findBottomSeries( s, plot.getData() );
+
+            if ( !other ) {
+                return;
+            }
+
+            var ps = datapoints.pointsize,
+                points = datapoints.points,
+                otherps = other.datapoints.pointsize,
+                otherpoints = other.datapoints.points,
+                newpoints = [],
+                px, py, intery, qx, qy, bottom,
+                withlines = s.lines.show,
+                withbottom = ps > 2 && datapoints.format[2].y,
+                withsteps = withlines && s.lines.steps,
+                fromgap = true,
+                i = 0,
+                j = 0,
+                l, m;
+
+            while ( true ) {
+
+                if ( i >= points.length ) {
+                    break;
+                }
+
+                l = newpoints.length;
+
+                if ( points[ i ] == null ) {
+
+                    // copy gaps
+
+                    for ( m = 0; m < ps; ++m ) {
+                        newpoints.push( points[ i + m ] );
+                    }
+
+                    i += ps;
+
+                } else if ( j >= otherpoints.length ) {
+
+                    // for lines, we can't use the rest of the points
+
+                    if ( !withlines ) {
+                        for ( m = 0; m < ps; ++m ) {
+                            newpoints.push( points[ i + m ] );
+                        }
+                    }
+
+                    i += ps;
+
+                } else if ( otherpoints[ j ] == null ) {
+
+                    // oops, got a gap
+
+                    for ( m = 0; m < ps; ++m ) {
+                        newpoints.push( null );
+                    }
+
+                    fromgap = true;
+                    j += otherps;
+
+                } else {
+
+                    // cases where we actually got two points
+
+                    px = points[ i ];
+                    py = points[ i + 1 ];
+                    qx = otherpoints[ j ];
+                    qy = otherpoints[ j + 1 ];
+                    bottom = 0;
+
+                    if ( px === qx ) {
+
+                        for ( m = 0; m < ps; ++m ) {
+                            newpoints.push( points[ i + m ] );
+                        }
+
+                        //newpoints[ l + 1 ] += qy;
+                        bottom = qy;
+
+                        i += ps;
+                        j += otherps;
+
+                    } else if ( px > qx ) {
+
+                        // we got past point below, might need to
+                        // insert interpolated extra point
+
+                        if ( withlines && i > 0 && points[ i - ps ] != null ) {
+                            intery = py + ( points[ i - ps + 1 ] - py ) * ( qx - px ) / ( points[ i - ps ] - px );
+                            newpoints.push( qx );
+                            newpoints.push( intery );
+                            for ( m = 2; m < ps; ++m ) {
+                                newpoints.push( points[ i + m ] );
+                            }
+                            bottom = qy;
+                        }
+
+                        j += otherps;
+
+                    } else { // px < qx
+
+                        // if we come from a gap, we just skip this point
+
+                        if ( fromgap && withlines ) {
+                            i += ps;
+                            continue;
+                        }
+
+                        for ( m = 0; m < ps; ++m ) {
+                            newpoints.push( points[ i + m ] );
+                        }
+
+                        // we might be able to interpolate a point below,
+                        // this can give us a better y
+
+                        if ( withlines && j > 0 && otherpoints[ j - otherps ] != null ) {
+                            bottom = qy + ( otherpoints[ j - otherps + 1 ] - qy ) * ( px - qx ) / ( otherpoints[ j - otherps ] - qx );
+                        }
+
+                        //newpoints[l + 1] += bottom;
+
+                        i += ps;
+                    }
+
+                    fromgap = false;
+
+                    if ( l !== newpoints.length && withbottom ) {
+                        newpoints[ l + 2 ] = bottom;
+                    }
+                }
+
+                // maintain the line steps invariant
+
+                if ( withsteps && l !== newpoints.length && l > 0 &&
+                    newpoints[ l ] !== null &&
+                    newpoints[ l ] !== newpoints[ l - ps ] &&
+                    newpoints[ l + 1 ] !== newpoints[ l - ps + 1 ] ) {
+                    for (m = 0; m < ps; ++m) {
+                        newpoints[ l + ps + m ] = newpoints[ l + m ];
+                    }
+                    newpoints[ l + 1 ] = newpoints[ l - ps + 1 ];
+                }
+            }
+
+            datapoints.points = newpoints;
+        }
+
+        plot.hooks.processDatapoints.push( computeFillBottoms );
+    }
+
+    $.plot.plugins.push({
+        init: init,
+        options: options,
+        name: "fillbetween",
+        version: "1.0"
+    });
+
+})(jQuery);
diff --git a/Websites/perf.webkit.org/public/js/jquery.flot.js b/Websites/perf.webkit.org/public/js/jquery.flot.js
new file mode 100644 (file)
index 0000000..77eb41f
--- /dev/null
@@ -0,0 +1,2691 @@
+/* Javascript plotting library for jQuery, version 0.8 alpha.
+
+Copyright (c) 2007-2012 IOLA and Ole Laursen.
+Licensed under the MIT license.
+
+*/
+
+// first an inline dependency, jquery.colorhelpers.js, we inline it here
+// for convenience
+
+/* Plugin for jQuery for working with colors.
+ *
+ * Version 1.1.
+ *
+ * Inspiration from jQuery color animation plugin by John Resig.
+ *
+ * Released under the MIT license by Ole Laursen, October 2009.
+ *
+ * Examples:
+ *
+ *   $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString()
+ *   var c = $.color.extract($("#mydiv"), 'background-color');
+ *   console.log(c.r, c.g, c.b, c.a);
+ *   $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)"
+ *
+ * Note that .scale() and .add() return the same modified object
+ * instead of making a new one.
+ *
+ * V. 1.1: Fix error handling so e.g. parsing an empty string does
+ * produce a color rather than just crashing.
+ */
+(function(B){B.color={};B.color.make=function(F,E,C,D){var G={};G.r=F||0;G.g=E||0;G.b=C||0;G.a=D!=null?D:1;G.add=function(J,I){for(var H=0;H<J.length;++H){G[J.charAt(H)]+=I}return G.normalize()};G.scale=function(J,I){for(var H=0;H<J.length;++H){G[J.charAt(H)]*=I}return G.normalize()};G.toString=function(){if(G.a>=1){return"rgb("+[G.r,G.g,G.b].join(",")+")"}else{return"rgba("+[G.r,G.g,G.b,G.a].join(",")+")"}};G.normalize=function(){function H(J,K,I){return K<J?J:(K>I?I:K)}G.r=H(0,parseInt(G.r),255);G.g=H(0,parseInt(G.g),255);G.b=H(0,parseInt(G.b),255);G.a=H(0,G.a,1);return G};G.clone=function(){return B.color.make(G.r,G.b,G.g,G.a)};return G.normalize()};B.color.extract=function(D,C){var E;do{E=D.css(C).toLowerCase();if(E!=""&&E!="transparent"){break}D=D.parent()}while(!B.nodeName(D.get(0),"body"));if(E=="rgba(0, 0, 0, 0)"){E="transparent"}return B.color.parse(E)};B.color.parse=function(F){var E,C=B.color.make;if(E=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10))}if(E=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10),parseFloat(E[4]))}if(E=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55)}if(E=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55,parseFloat(E[4]))}if(E=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(F)){return C(parseInt(E[1],16),parseInt(E[2],16),parseInt(E[3],16))}if(E=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(F)){return C(parseInt(E[1]+E[1],16),parseInt(E[2]+E[2],16),parseInt(E[3]+E[3],16))}var D=B.trim(F).toLowerCase();if(D=="transparent"){return C(255,255,255,0)}else{E=A[D]||[0,0,0];return C(E[0],E[1],E[2])}};var A={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);
+
+// the actual Flot code
+(function($) {
+    function Plot(placeholder, data_, options_, plugins) {
+        // data is on the form:
+        //   [ series1, series2 ... ]
+        // where series is either just the data as [ [x1, y1], [x2, y2], ... ]
+        // or { data: [ [x1, y1], [x2, y2], ... ], label: "some label", ... }
+
+        var series = [],
+            options = {
+                // the color theme used for graphs
+                colors: ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"],
+                legend: {
+                    show: true,
+                    noColumns: 1, // number of colums in legend table
+                    labelFormatter: null, // fn: string -> string
+                    labelBoxBorderColor: "#ccc", // border color for the little label boxes
+                    container: null, // container (as jQuery object) to put legend in, null means default on top of graph
+                    position: "ne", // position of default legend container within plot
+                    margin: 5, // distance from grid edge to default legend container within plot
+                    backgroundColor: null, // null means auto-detect
+                    backgroundOpacity: 0.85, // set to 0 to avoid background
+                    sorted: null    // default to no legend sorting
+                },
+                xaxis: {
+                    show: null, // null = auto-detect, true = always, false = never
+                    position: "bottom", // or "top"
+                    mode: null, // null or "time"
+                    timezone: null, // "browser" for local to the client or timezone for timezone-js
+                    font: null, // null (derived from CSS in placeholder) or object like { size: 11, style: "italic", weight: "bold", family: "sans-serif", variant: "small-caps" }
+                    color: null, // base color, labels, ticks
+                    tickColor: null, // possibly different color of ticks, e.g. "rgba(0,0,0,0.15)"
+                    transform: null, // null or f: number -> number to transform axis
+                    inverseTransform: null, // if transform is set, this should be the inverse function
+                    min: null, // min. value to show, null means set automatically
+                    max: null, // max. value to show, null means set automatically
+                    autoscaleMargin: null, // margin in % to add if auto-setting min/max
+                    ticks: null, // either [1, 3] or [[1, "a"], 3] or (fn: axis info -> ticks) or app. number of ticks for auto-ticks
+                    tickFormatter: null, // fn: number -> string
+                    labelWidth: null, // size of tick labels in pixels
+                    labelHeight: null,
+                    reserveSpace: null, // whether to reserve space even if axis isn't shown
+                    tickLength: null, // size in pixels of ticks, or "full" for whole line
+                    alignTicksWithAxis: null, // axis number or null for no sync
+
+                    // mode specific options
+                    tickDecimals: null, // no. of decimals, null means auto
+                    tickSize: null, // number or [number, "unit"]
+                    minTickSize: null, // number or [number, "unit"]
+                    monthNames: null, // list of names of months
+                    timeformat: null, // format string to use
+                    twelveHourClock: false // 12 or 24 time in time mode
+                },
+                yaxis: {
+                    autoscaleMargin: 0.02,
+                    position: "left" // or "right"
+                },
+                xaxes: [],
+                yaxes: [],
+                series: {
+                    points: {
+                        show: false,
+                        radius: 3,
+                        lineWidth: 2, // in pixels
+                        fill: true,
+                        fillColor: "#ffffff",
+                        symbol: "circle" // or callback
+                    },
+                    lines: {
+                        // we don't put in show: false so we can see
+                        // whether lines were actively disabled
+                        lineWidth: 2, // in pixels
+                        fill: false,
+                        fillColor: null,
+                        steps: false
+                        // Omit 'zero', so we can later default its value to
+                        // match that of the 'fill' option.
+                    },
+                    bars: {
+                        show: false,
+                        lineWidth: 2, // in pixels
+                        barWidth: 1, // in units of the x axis
+                        fill: true,
+                        fillColor: null,
+                        align: "left", // "left", "right", or "center"
+                        horizontal: false,
+                        zero: true
+                    },
+                    shadowSize: 3,
+                    highlightColor: null
+                },
+                grid: {
+                    show: true,
+                    aboveData: false,
+                    color: "#545454", // primary color used for outline and labels
+                    backgroundColor: null, // null for transparent, else color
+                    borderColor: null, // set if different from the grid color
+                    tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)"
+                    margin: 0, // distance from the canvas edge to the grid
+                    labelMargin: 5, // in pixels
+                    axisMargin: 8, // in pixels
+                    borderWidth: 2, // in pixels
+                    minBorderMargin: null, // in pixels, null means taken from points radius
+                    markings: null, // array of ranges or fn: axes -> array of ranges
+                    markingsColor: "#f4f4f4",
+                    markingsLineWidth: 2,
+                    // interactive stuff
+                    clickable: false,
+                    hoverable: false,
+                    autoHighlight: true, // highlight in case mouse is near
+                    mouseActiveRadius: 10 // how far the mouse can be away to activate an item
+                },
+                interaction: {
+                    redrawOverlayInterval: 1000/60 // time between updates, -1 means in same flow
+                },
+                hooks: {}
+            },
+        canvas = null,      // the canvas for the plot itself
+        overlay = null,     // canvas for interactive stuff on top of plot
+        eventHolder = null, // jQuery object that events should be bound to
+        ctx = null, octx = null,
+        xaxes = [], yaxes = [],
+        plotOffset = { left: 0, right: 0, top: 0, bottom: 0},
+        canvasWidth = 0, canvasHeight = 0,
+        plotWidth = 0, plotHeight = 0,
+        hooks = {
+            processOptions: [],
+            processRawData: [],
+            processDatapoints: [],
+            processOffset: [],
+            drawBackground: [],
+            drawSeries: [],
+            draw: [],
+            bindEvents: [],
+            drawOverlay: [],
+            shutdown: []
+        },
+        plot = this;
+
+        // public functions
+        plot.setData = setData;
+        plot.setupGrid = setupGrid;
+        plot.draw = draw;
+        plot.getPlaceholder = function() { return placeholder; };
+        plot.getCanvas = function() { return canvas; };
+        plot.getPlotOffset = function() { return plotOffset; };
+        plot.width = function () { return plotWidth; };
+        plot.height = function () { return plotHeight; };
+        plot.offset = function () {
+            var o = eventHolder.offset();
+            o.left += plotOffset.left;
+            o.top += plotOffset.top;
+            return o;
+        };
+        plot.getData = function () { return series; };
+        plot.getAxes = function () {
+            var res = {}, i;
+            $.each(xaxes.concat(yaxes), function (_, axis) {
+                if (axis)
+                    res[axis.direction + (axis.n != 1 ? axis.n : "") + "axis"] = axis;
+            });
+            return res;
+        };
+        plot.getXAxes = function () { return xaxes; };
+        plot.getYAxes = function () { return yaxes; };
+        plot.c2p = canvasToAxisCoords;
+        plot.p2c = axisToCanvasCoords;
+        plot.getOptions = function () { return options; };
+        plot.highlight = highlight;
+        plot.unhighlight = unhighlight;
+        plot.triggerRedrawOverlay = triggerRedrawOverlay;
+        plot.pointOffset = function(point) {
+            return {
+                left: parseInt(xaxes[axisNumber(point, "x") - 1].p2c(+point.x) + plotOffset.left, 10),
+                top: parseInt(yaxes[axisNumber(point, "y") - 1].p2c(+point.y) + plotOffset.top, 10)
+            };
+        };
+        plot.shutdown = shutdown;
+        plot.resize = function () {
+            getCanvasDimensions();
+            resizeCanvas(canvas);
+            resizeCanvas(overlay);
+        };
+
+        // public attributes
+        plot.hooks = hooks;
+
+        // initialize
+        initPlugins(plot);
+        parseOptions(options_);
+        setupCanvases();
+        setData(data_);
+        setupGrid();
+        draw();
+        bindEvents();
+
+
+        function executeHooks(hook, args) {
+            args = [plot].concat(args);
+            for (var i = 0; i < hook.length; ++i)
+                hook[i].apply(this, args);
+        }
+
+        function initPlugins() {
+            for (var i = 0; i < plugins.length; ++i) {
+                var p = plugins[i];
+                p.init(plot);
+                if (p.options)
+                    $.extend(true, options, p.options);
+            }
+        }
+
+        function parseOptions(opts) {
+            var i;
+
+            $.extend(true, options, opts);
+
+            if (options.xaxis.color == null)
+                options.xaxis.color = options.grid.color;
+            if (options.yaxis.color == null)
+                options.yaxis.color = options.grid.color;
+
+            if (options.xaxis.tickColor == null) // backwards-compatibility
+                options.xaxis.tickColor = options.grid.tickColor;
+            if (options.yaxis.tickColor == null) // backwards-compatibility
+                options.yaxis.tickColor = options.grid.tickColor;
+
+            if (options.grid.borderColor == null)
+                options.grid.borderColor = options.grid.color;
+            if (options.grid.tickColor == null)
+                options.grid.tickColor = $.color.parse(options.grid.color).scale('a', 0.22).toString();
+
+            // fill in defaults in axes, copy at least always the
+            // first as the rest of the code assumes it'll be there
+            for (i = 0; i < Math.max(1, options.xaxes.length); ++i)
+                options.xaxes[i] = $.extend(true, {}, options.xaxis, options.xaxes[i]);
+            for (i = 0; i < Math.max(1, options.yaxes.length); ++i)
+                options.yaxes[i] = $.extend(true, {}, options.yaxis, options.yaxes[i]);
+
+            // backwards compatibility, to be removed in future
+            if (options.xaxis.noTicks && options.xaxis.ticks == null)
+                options.xaxis.ticks = options.xaxis.noTicks;
+            if (options.yaxis.noTicks && options.yaxis.ticks == null)
+                options.yaxis.ticks = options.yaxis.noTicks;
+            if (options.x2axis) {
+                options.xaxes[1] = $.extend(true, {}, options.xaxis, options.x2axis);
+                options.xaxes[1].position = "top";
+            }
+            if (options.y2axis) {
+                options.yaxes[1] = $.extend(true, {}, options.yaxis, options.y2axis);
+                options.yaxes[1].position = "right";
+            }
+            if (options.grid.coloredAreas)
+                options.grid.markings = options.grid.coloredAreas;
+            if (options.grid.coloredAreasColor)
+                options.grid.markingsColor = options.grid.coloredAreasColor;
+            if (options.lines)
+                $.extend(true, options.series.lines, options.lines);
+            if (options.points)
+                $.extend(true, options.series.points, options.points);
+            if (options.bars)
+                $.extend(true, options.series.bars, options.bars);
+            if (options.shadowSize != null)
+                options.series.shadowSize = options.shadowSize;
+            if (options.highlightColor != null)
+                options.series.highlightColor = options.highlightColor;
+
+            // save options on axes for future reference
+            for (i = 0; i < options.xaxes.length; ++i)
+                getOrCreateAxis(xaxes, i + 1).options = options.xaxes[i];
+            for (i = 0; i < options.yaxes.length; ++i)
+                getOrCreateAxis(yaxes, i + 1).options = options.yaxes[i];
+
+            // add hooks from options
+            for (var n in hooks)
+                if (options.hooks[n] && options.hooks[n].length)
+                    hooks[n] = hooks[n].concat(options.hooks[n]);
+
+            executeHooks(hooks.processOptions, [options]);
+        }
+
+        function setData(d) {
+            series = parseData(d);
+            fillInSeriesOptions();
+            processData();
+        }
+
+        function parseData(d) {
+            var res = [];
+            for (var i = 0; i < d.length; ++i) {
+                var s = $.extend(true, {}, options.series);
+
+                if (d[i].data != null) {
+                    s.data = d[i].data; // move the data instead of deep-copy
+                    delete d[i].data;
+
+                    $.extend(true, s, d[i]);
+
+                    d[i].data = s.data;
+                }
+                else
+                    s.data = d[i];
+                res.push(s);
+            }
+
+            return res;
+        }
+
+        function axisNumber(obj, coord) {
+            var a = obj[coord + "axis"];
+            if (typeof a == "object") // if we got a real axis, extract number
+                a = a.n;
+            if (typeof a != "number")
+                a = 1; // default to first axis
+            return a;
+        }
+
+        function allAxes() {
+            // return flat array without annoying null entries
+            return $.grep(xaxes.concat(yaxes), function (a) { return a; });
+        }
+
+        function canvasToAxisCoords(pos) {
+            // return an object with x/y corresponding to all used axes
+            var res = {}, i, axis;
+            for (i = 0; i < xaxes.length; ++i) {
+                axis = xaxes[i];
+                if (axis && axis.used)
+                    res["x" + axis.n] = axis.c2p(pos.left);
+            }
+
+            for (i = 0; i < yaxes.length; ++i) {
+                axis = yaxes[i];
+                if (axis && axis.used)
+                    res["y" + axis.n] = axis.c2p(pos.top);
+            }
+
+            if (res.x1 !== undefined)
+                res.x = res.x1;
+            if (res.y1 !== undefined)
+                res.y = res.y1;
+
+            return res;
+        }
+
+        function axisToCanvasCoords(pos) {
+            // get canvas coords from the first pair of x/y found in pos
+            var res = {}, i, axis, key;
+
+            for (i = 0; i < xaxes.length; ++i) {
+                axis = xaxes[i];
+                if (axis && axis.used) {
+                    key = "x" + axis.n;
+                    if (pos[key] == null && axis.n == 1)
+                        key = "x";
+
+                    if (pos[key] != null) {
+                        res.left = axis.p2c(pos[key]);
+                        break;
+                    }
+                }
+            }
+
+            for (i = 0; i < yaxes.length; ++i) {
+                axis = yaxes[i];
+                if (axis && axis.used) {
+                    key = "y" + axis.n;
+                    if (pos[key] == null && axis.n == 1)
+                        key = "y";
+
+                    if (pos[key] != null) {
+                        res.top = axis.p2c(pos[key]);
+                        break;
+                    }
+                }
+            }
+
+            return res;
+        }
+
+        function getOrCreateAxis(axes, number) {
+            if (!axes[number - 1])
+                axes[number - 1] = {
+                    n: number, // save the number for future reference
+                    direction: axes == xaxes ? "x" : "y",
+                    options: $.extend(true, {}, axes == xaxes ? options.xaxis : options.yaxis)
+                };
+
+            return axes[number - 1];
+        }
+
+        function fillInSeriesOptions() {
+
+            var neededColors = series.length, maxIndex = -1, i;
+
+            // Subtract the number of series that already have fixed colors or
+            // color indexes from the number that we still need to generate.
+
+            for (i = 0; i < series.length; ++i) {
+                var sc = series[i].color;
+                if (sc != null) {
+                    neededColors--;
+                    if (typeof sc == "number" && sc > maxIndex) {
+                        maxIndex = sc;
+                    }
+                }
+            }
+
+            // If any of the series have fixed color indexes, then we need to
+            // generate at least as many colors as the highest index.
+
+            if (neededColors <= maxIndex) {
+                neededColors = maxIndex + 1;
+            }
+
+            // Generate all the colors, using first the option colors and then
+            // variations on those colors once they're exhausted.
+
+            var c, colors = [], colorPool = options.colors,
+                colorPoolSize = colorPool.length, variation = 0;
+
+            for (i = 0; i < neededColors; i++) {
+
+                c = $.color.parse(colorPool[i % colorPoolSize] || "#666");
+
+                // Each time we exhaust the colors in the pool we adjust
+                // a scaling factor used to produce more variations on
+                // those colors. The factor alternates negative/positive
+                // to produce lighter/darker colors.
+
+                // Reset the variation after every few cycles, or else
+                // it will end up producing only white or black colors.
+
+                if (i % colorPoolSize == 0 && i) {
+                    if (variation >= 0) {
+                        if (variation < 0.5) {
+                            variation = -variation - 0.2;
+                        } else variation = 0;
+                    } else variation = -variation;
+                }
+
+                colors[i] = c.scale('rgb', 1 + variation);
+            }
+
+            // Finalize the series options, filling in their colors
+
+            var colori = 0, s;
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+
+                // assign colors
+                if (s.color == null) {
+                    s.color = colors[colori].toString();
+                    ++colori;
+                }
+                else if (typeof s.color == "number")
+                    s.color = colors[s.color].toString();
+
+                // turn on lines automatically in case nothing is set
+                if (s.lines.show == null) {
+                    var v, show = true;
+                    for (v in s)
+                        if (s[v] && s[v].show) {
+                            show = false;
+                            break;
+                        }
+                    if (show)
+                        s.lines.show = true;
+                }
+
+                // If nothing was provided for lines.zero, default it to match
+                // lines.fill, since areas by default should extend to zero.
+
+                if (s.lines.zero == null) {
+                    s.lines.zero = !!s.lines.fill;
+                }
+
+                // setup axes
+                s.xaxis = getOrCreateAxis(xaxes, axisNumber(s, "x"));
+                s.yaxis = getOrCreateAxis(yaxes, axisNumber(s, "y"));
+            }
+        }
+
+        function processData() {
+            var topSentry = Number.POSITIVE_INFINITY,
+                bottomSentry = Number.NEGATIVE_INFINITY,
+                fakeInfinity = Number.MAX_VALUE,
+                i, j, k, m, length,
+                s, points, ps, x, y, axis, val, f, p,
+                data, format;
+
+            function updateAxis(axis, min, max) {
+                if (min < axis.datamin && min != -fakeInfinity)
+                    axis.datamin = min;
+                if (max > axis.datamax && max != fakeInfinity)
+                    axis.datamax = max;
+            }
+
+            $.each(allAxes(), function (_, axis) {
+                // init axis
+                axis.datamin = topSentry;
+                axis.datamax = bottomSentry;
+                axis.used = false;
+            });
+
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+                s.datapoints = { points: [] };
+
+                executeHooks(hooks.processRawData, [ s, s.data, s.datapoints ]);
+            }
+
+            // first pass: clean and copy data
+            for (i = 0; i < series.length; ++i) {
+                s = series[i];
+
+                data = s.data;
+                format = s.datapoints.format;
+
+                if (!format) {
+                    format = [];
+                    // find out how to copy
+                    format.push({ x: true, number: true, required: true });
+                    format.push({ y: true, number: true, required: true });
+
+                    if (s.bars.show || (s.lines.show && s.lines.fill)) {
+                        var autoscale = !!((s.bars.show && s.bars.zero) || (s.lines.show && s.lines.zero));
+                        format.push({ y: true, number: true, required: false, defaultValue: 0, autoscale: autoscale });
+                        if (s.bars.horizontal) {
+                            delete format[format.length - 1].y;
+                            format[format.length - 1].x = true;
+                        }
+                    }
+
+                    s.datapoints.format = format;
+                }
+
+                if (s.datapoints.pointsize != null)
+                    continue; // already filled in
+
+                s.datapoints.pointsize = format.length;
+
+                ps = s.datapoints.pointsize;
+                points = s.datapoints.points;
+
+                var insertSteps = s.lines.show && s.lines.steps;
+                s.xaxis.used = s.yaxis.used = true;
+
+                for (j = k = 0; j < data.length; ++j, k += ps) {
+                    p = data[j];
+
+                    var nullify = p == null;
+                    if (!nullify) {
+                        for (m = 0; m < ps; ++m) {
+                            val = p[m];
+                            f = format[m];
+
+                            if (f) {
+                                if (f.number && val != null) {
+                                    val = +val; // convert to number
+                                    if (isNaN(val))
+                                        val = null;
+                                    else if (val == Infinity)
+                                        val = fakeInfinity;
+                                    else if (val == -Infinity)
+                                        val = -fakeInfinity;
+                                }
+
+                                if (val == null) {
+                                    if (f.required)
+                                        nullify = true;
+
+                                    if (f.defaultValue != null)
+                                        val = f.defaultValue;
+                                }
+                            }
+<