--- /dev/null
+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.
+
--- /dev/null
+# 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`
--- /dev/null
+# 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.
--- /dev/null
+{
+ "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
+ }
+}
--- /dev/null
+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);
--- /dev/null
+This directory should be password-protected unless you want anyone to be able to mess with the database.
--- /dev/null
+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
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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é Descartes</p><?php
+
+require('../include/admin-footer.php');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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é Descartes</p><?php
+
+require('../include/admin-footer.php');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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();
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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');
+
+?>
--- /dev/null
+<?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);
+
+?>
--- /dev/null
+<?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);
+
+?>
--- /dev/null
+
+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;
+}
--- /dev/null
+</div>
+
+</body>
+</html>
--- /dev/null
+<?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;
+
+ }
+
+}
+
+?>
--- /dev/null
+<?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');
+ }
+
+}
+
+?>
--- /dev/null
+<?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);
+ }
+}
+
+?>
--- /dev/null
+<?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;
+ }
+}
+
+?>
--- /dev/null
+<?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);
+ }
+};
+
+?>
--- /dev/null
+<?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);
+ }
+}
+
+?>
--- /dev/null
+<!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>
--- /dev/null
+
+// 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 += ' ± ' + 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);
+ }
+}
--- /dev/null
+/* 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);
--- /dev/null
+/* 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);
--- /dev/null
+/* 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);
--- /dev/null
+/* 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);
--- /dev/null
+/* 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);
--- /dev/null
+/* 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;
+ &n