diff --git a/app/bin/build_and_test.sh b/app/bin/build_and_test.sh index a5afb6a3ee..6247d72edc 100755 --- a/app/bin/build_and_test.sh +++ b/app/bin/build_and_test.sh @@ -11,7 +11,9 @@ fi rm -rf build pub get -pub run test +dartanalyzer --strong bin/*.dart web/*.dart test/*.dart +pub run test -p vm +pub run test -p dartium pub build cp web/*.dart build/web/ cp -RL packages build/web/ diff --git a/app/lib/components/benchmark_grid.dart b/app/lib/components/benchmark_grid.dart new file mode 100644 index 0000000000..9ef4b9fa89 --- /dev/null +++ b/app/lib/components/benchmark_grid.dart @@ -0,0 +1,127 @@ +// Copyright (c) 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show JSON; +import 'dart:html'; +import 'dart:math' as math; + +import 'package:angular2/angular2.dart'; +import 'package:cocoon/model.dart'; +import 'package:http/http.dart' as http; + +@Component( + selector: 'benchmark-grid', + template: r''' +
Loading...
+
+ + +
+''', + directives: const [NgIf, NgFor, NgClass, BenchmarkCard], +) +class BenchmarkGrid implements OnInit, OnDestroy { + BenchmarkGrid(this._httpClient); + + final http.Client _httpClient; + bool isLoading = true; + List benchmarks; + Timer _reloadTimer; + + @override + void ngOnInit() { + reloadData(); + _reloadTimer = new Timer.periodic(const Duration(seconds: 30), (_) => reloadData()); + } + + @override + void ngOnDestroy() { + _reloadTimer?.cancel(); + } + + Future reloadData() async { + isLoading = true; + Map statusJson = JSON.decode((await _httpClient.get('/api/get-benchmarks')).body); + GetBenchmarksResult result = GetBenchmarksResult.fromJson(statusJson); + benchmarks = result.benchmarks; + isLoading = false; + } +} + +@Component( + selector: 'benchmark-card', + template: r''' +
+ {{latestValue}} + {{unit}} +
+
{{label}}
+
+ ''', + directives: const [NgIf, NgFor, NgStyle], +) +class BenchmarkCard implements AfterViewInit { + BenchmarkData _data; + + @ViewChild('chartContainer') ElementRef chartContainer; + + @Input() set data(BenchmarkData newData) { + chartContainer.nativeElement.children.clear(); + _data = newData; + } + + String get id => _data.timeseries.timeseries.id; + String get label => _data.timeseries.timeseries.label; + String get unit => _data.timeseries.timeseries.unit; + String get latestValue { + if (_data.values.isEmpty) return null; + num value = _data.values.first.value; + if (value > 100) { + // Ignore fractions in large values. + value = value.round(); + } + if (value < 100000) { + return value.toString(); + } else { + // The value is too big to fit on the card; switch to thousands. + return '${value ~/ 1000}K'; + } + } + + @override + void ngAfterViewInit() { + if (_data.values.isEmpty) return; + double maxValue = _data.values + .map((TimeseriesValue v) => v.value) + .reduce(math.max); + + for (TimeseriesValue value in _data.values.reversed) { + DivElement bar = new DivElement() + ..classes.add('metric-value-bar') + ..style.height = '${80 * value.value / maxValue}px'; + + DivElement tooltip; + bar.onMouseOver.listen((_) { + tooltip = new DivElement() + ..text = '${value.value}$unit\n' + 'Flutter revision: ${value.revision}\n' + 'Recorded on: ${new DateTime.fromMillisecondsSinceEpoch(value.createTimestamp)}' + ..classes.add('metric-value-tooltip') + ..style.top = '${bar.getBoundingClientRect().top}px' + ..style.left = '${bar.getBoundingClientRect().right + 5}px'; + bar.style.backgroundColor = '#11CC11'; + document.body.append(tooltip); + }); + bar.onMouseOut.listen((_) { + bar.style.backgroundColor = '#AAFFAA'; + tooltip?.remove(); + }); + + chartContainer.nativeElement.children.add(bar); + } + } +} diff --git a/app/lib/http.dart b/app/lib/http.dart new file mode 100644 index 0000000000..b69c983dcf --- /dev/null +++ b/app/lib/http.dart @@ -0,0 +1,32 @@ +// Copyright (c) 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:html'; + +import 'package:angular2/core.dart'; +import 'package:http/browser_client.dart' as browser_http; +import 'package:http/http.dart' as http; + +Future getAuthenticatedClientOrRedirectToSignIn() async { + http.Client client = new browser_http.BrowserClient(); + Map status = JSON.decode((await client.get('/api/get-authentication-status')).body); + + document.querySelector('#logout-button').on['click'].listen((_) { + window.open(status['LogoutURL'], '_self'); + }); + + document.querySelector('#login-button').on['click'].listen((_) { + window.open(status['LoginURL'], '_self'); + }); + + if (status['Status'] == 'OK') + return client; + + document.body.append(new DivElement() + ..text = 'You are not signed in, or signed in under an unauthorized account. ' + 'Use the buttons at the bottom of this page to sign in.'); + return null; +} diff --git a/app/lib/model.dart b/app/lib/model.dart index 58d6712bfe..e2140b39aa 100644 --- a/app/lib/model.dart +++ b/app/lib/model.dart @@ -201,3 +201,83 @@ class Task extends Entity { DateTime get endTimestamp => this['EndTimestamp']; int get attempts => this['Attempts']; } + +class GetBenchmarksResult extends Entity { + static final _serializer = new EntitySerializer( + (Map props) => new GetBenchmarksResult(props), + { + 'Benchmarks': listOf(BenchmarkData._serializer), + } + ); + + GetBenchmarksResult([Map props]) : super(_serializer, props); + + static GetBenchmarksResult fromJson(dynamic json) => + _serializer.deserialize(json); + + List get benchmarks => this['Benchmarks']; +} + +class BenchmarkData extends Entity { + static final _serializer = new EntitySerializer( + (Map props) => new BenchmarkData(props), + { + 'Timeseries': TimeseriesEntity._serializer, + 'Values': listOf(TimeseriesValue._serializer), + } + ); + + BenchmarkData([Map props]) : super(_serializer, props); + + TimeseriesEntity get timeseries => this['Timeseries']; + List get values => this['Values']; +} + +class TimeseriesEntity extends Entity { + static final _serializer = new EntitySerializer( + (Map props) => new TimeseriesEntity(props), + { + 'Key': Key._serializer, + 'Timeseries': Timeseries._serializer, + } + ); + + TimeseriesEntity([Map props]) : super(_serializer, props); + + Key get key => this['Key']; + Timeseries get timeseries => this['Timeseries']; +} + +class Timeseries extends Entity { + static final _serializer = new EntitySerializer( + (Map props) => new Timeseries(props), + { + 'ID': string(), + 'Label': string(), + 'Unit': string(), + } + ); + + Timeseries([Map props]) : super(_serializer, props); + + String get id => this['ID']; + String get label => this['Label']; + String get unit => this['Unit']; +} + +class TimeseriesValue extends Entity { + static final _serializer = new EntitySerializer( + (Map props) => new TimeseriesValue(props), + { + 'CreateTimestamp': number(), + 'Revision': string(), + 'Value': number(), + } + ); + + TimeseriesValue([Map props]) : super(_serializer, props); + + int get createTimestamp => this['CreateTimestamp']; + String get revision => this['Revision']; + double get value => this['Value']; +} diff --git a/app/main.go b/app/main.go index 43a673f952..76fc96cfb5 100644 --- a/app/main.go +++ b/app/main.go @@ -28,6 +28,7 @@ import ( func init() { registerRPC("/api/create-agent", commands.CreateAgent) registerRPC("/api/authorize-agent", commands.AuthorizeAgent) + registerRPC("/api/get-benchmarks", commands.GetBenchmarks) registerRPC("/api/get-status", commands.GetStatus) registerRPC("/api/refresh-github-commits", commands.RefreshGithubCommits) registerRPC("/api/refresh-travis-status", commands.RefreshTravisStatus) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 15a6837914..22fcc6ec4f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -19,7 +19,9 @@ transformers: - angular2: platform_directives: - 'package:angular2/common.dart#COMMON_DIRECTIVES' - entry_points: web/main.dart + entry_points: + - web/build.dart + - web/benchmarks.dart resolved_identifiers: Client: 'package:http/http.dart' - $dart2js: diff --git a/app/test/benchmark_grid_test.dart b/app/test/benchmark_grid_test.dart new file mode 100644 index 0000000000..1b9683b5d3 --- /dev/null +++ b/app/test/benchmark_grid_test.dart @@ -0,0 +1,72 @@ +// Copyright (c) 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +@TestOn('dartium') + +import 'dart:async'; +import 'dart:convert' show JSON; +import 'dart:html'; +import 'dart:math' as math; + +import 'package:angular2/angular2.dart'; +import 'package:angular2/platform/browser.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +import 'package:cocoon/components/benchmark_grid.dart'; + +void main() { + group('BenchmarkGrid', () { + setUp(() { + document.body.append(new Element.tag('benchmark-grid')); + }); + + tearDown(() { + for (Node node in document.body.querySelectorAll('benchmark-grid')) { + node.remove(); + } + }); + + test('should show a grid', () async { + List httpCalls = []; + + await bootstrap(BenchmarkGrid, [ + provide(http.Client, useValue: new MockClient((http.Request request) async { + httpCalls.add(request.url.path); + return new http.Response( + JSON.encode(_testData), + 200, + headers: {'content-type': 'application/json'}); + })), + ]); + + // Flush microtasks to allow Angular do its thing. + await new Future.delayed(Duration.ZERO); + + expect(httpCalls, ['/api/get-benchmarks']); + expect(document.querySelectorAll('benchmark-card'), + hasLength(_testData['Benchmarks'].length)); + }); + }); +} + +final math.Random _rnd = new math.Random(1234); + +final _testData = { + 'Benchmarks': new List.generate(5, (int i) => { + 'Timeseries': { + 'Key': 'key$i', + 'Timeseries': { + 'ID': 'series$i', + 'Label': 'Series $i', + 'Unit': 'ms', + }, + }, + 'Values': new List.generate(_rnd.nextInt(5), (int v) => { + 'CreateTimestamp': 1234567890000 - v, + 'Revision': 'rev$v', + 'Value': (50 + _rnd.nextInt(50)).toDouble(), + }), + }), +}; diff --git a/app/test/entity_test.dart b/app/test/entity_test.dart index 8adbdd8196..980b176db8 100644 --- a/app/test/entity_test.dart +++ b/app/test/entity_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:cocoon/entity.dart'; diff --git a/app/test/model_test.dart b/app/test/model_test.dart index 9c1ef1242e..a1e55aae41 100644 --- a/app/test/model_test.dart +++ b/app/test/model_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:cocoon/model.dart'; diff --git a/app/web/benchmarks.css b/app/web/benchmarks.css new file mode 100644 index 0000000000..0374f8e037 --- /dev/null +++ b/app/web/benchmarks.css @@ -0,0 +1,99 @@ +/* + Copyright (c) 2016 The Chromium Authors. All rights reserved. + Use of this source code is governed by a BSD-style license that can be + found in the LICENSE file. +*/ + +@import url(https://fonts.googleapis.com/css?family=Roboto:300); + +body { + background: #eee; + font: 16px Roboto, sans-serif; + font-weight: 300; + color: #333; + padding: 0; + margin: 0; +} + +.card-container { + display: flex; + flex-direction: row; + flex-flow: wrap; +} + +benchmark-card { + display: inline-block; + position: relative; + width: 250px; + height: 80px; + padding: 0; + margin: 5px; + + background: #f7f7f7; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + text-align: center; + + /* center the inside contents */ + display: flex; + flex-direction: row-reverse; + overflow: hidden; +} + +.metric { + position: absolute; + top: 5px; + left: 5px; + pointer-events: none; +} + +.metric-value { + font-size: 42px; + font-weight: bold; +} + +.metric-value-bar { + width: 5px; + background-color: #AAFFAA; + display: inline-block; +} + +.metric-value-tooltip { + background-color: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + border-radius: 4px; + padding: 5px; + position: fixed; + white-space: pre; +} + +.metric-unit { + font-size: 42px; + color: #CCC; +} + +.metric-label { + position: absolute; + bottom: 5px; + left: 5px; + pointer-events: none; +} + +footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 2px 19px; + z-index: 2; + background-color: #917FFF; + display: flex; +} + +footer span { + vertical-align: bottom; +} + +footer i.material-icons { + vertical-align: bottom; +} diff --git a/app/web/benchmarks.dart b/app/web/benchmarks.dart new file mode 100644 index 0000000000..bc10e4eb4f --- /dev/null +++ b/app/web/benchmarks.dart @@ -0,0 +1,39 @@ +// Copyright (c) 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html'; + +import 'package:angular2/core.dart'; +import 'package:angular2/platform/browser.dart'; +import 'package:http/http.dart' as http; + +import 'package:cocoon/components/benchmark_grid.dart'; +import 'package:cocoon/http.dart'; +import 'package:cocoon/logging.dart'; + +@AngularEntrypoint() +Future main() async { + logger = new HtmlLogger(); + http.Client httpClient = await getAuthenticatedClientOrRedirectToSignIn(); + + if (httpClient == null) + return null; + + // Start the angular app + await bootstrap(BenchmarkGrid, [ + provide(http.Client, useValue: httpClient), + ]); +} + +class HtmlLogger implements Logger { + @override + void info(String message) => window.console.log(message); + + @override + void warning(String message) => window.console.warn(message); + + @override + void error(String message) => window.console.error(message); +} diff --git a/app/web/benchmarks.html b/app/web/benchmarks.html new file mode 100644 index 0000000000..bde6d2fc71 --- /dev/null +++ b/app/web/benchmarks.html @@ -0,0 +1,39 @@ + + + + + + + + + Flutter Benchmarks + + + + + + +
+ +
+ +
+ + +
+ + + + + diff --git a/app/web/main.dart b/app/web/build.dart similarity index 54% rename from app/web/main.dart rename to app/web/build.dart index 98a6c357a8..d1116badc1 100644 --- a/app/web/main.dart +++ b/app/web/build.dart @@ -3,22 +3,21 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert'; import 'dart:html'; import 'package:angular2/core.dart'; import 'package:angular2/platform/browser.dart'; -import 'package:http/browser_client.dart' as browser_http; import 'package:http/http.dart' as http; +import 'package:cocoon/cli.dart'; import 'package:cocoon/components/status_table.dart'; +import 'package:cocoon/http.dart'; import 'package:cocoon/logging.dart'; -import 'package:cocoon/cli.dart'; @AngularEntrypoint() Future main() async { logger = new HtmlLogger(); - http.Client httpClient = await _getAuthenticatedClientOrRedirectToSignIn(); + http.Client httpClient = await getAuthenticatedClientOrRedirectToSignIn(); if (httpClient == null) return; @@ -32,28 +31,6 @@ Future main() async { Cli.install(ref.injector); } -Future _getAuthenticatedClientOrRedirectToSignIn() async { - http.Client client = new browser_http.BrowserClient(); - Map status = JSON.decode((await client.get('/api/get-authentication-status')).body); - - document.querySelector('#logout-button').on['click'].listen((_) { - window.open(status['LogoutURL'], '_self'); - }); - - document.querySelector('#login-button').on['click'].listen((_) { - window.open(status['LoginURL'], '_self'); - }); - - if (status['Status'] == 'OK') { - return client; - } - - document.body.append(new DivElement() - ..text = 'You are not signed in, or signed in under an unauthorized account. ' - 'Use the buttons at the bottom of this page to sign in.'); - return null; -} - class HtmlLogger implements Logger { @override void info(String message) => window.console.log(message); diff --git a/app/web/build.html b/app/web/build.html index 27a58a584a..bb8c15a6fa 100644 --- a/app/web/build.html +++ b/app/web/build.html @@ -34,7 +34,7 @@ - + diff --git a/commands/get_benchmarks.go b/commands/get_benchmarks.go new file mode 100644 index 0000000000..03a137377d --- /dev/null +++ b/commands/get_benchmarks.go @@ -0,0 +1,49 @@ +// Copyright (c) 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package commands + +import "cocoon/db" + +// GetBenchmarksCommand returns recent benchmark results. +type GetBenchmarksCommand struct { +} + +// GetBenchmarksResult contains recent benchmark results. +type GetBenchmarksResult struct { + Benchmarks []*BenchmarkData +} + +// BenchmarkData contains benchmark data. +type BenchmarkData struct { + Timeseries *db.TimeseriesEntity + Values []*db.TimeseriesValue +} + +// GetBenchmarks returns recent benchmark results. +func GetBenchmarks(c *db.Cocoon, inputJSON []byte) (interface{}, error) { + seriesList, err := c.QueryTimeseries() + + if err != nil { + return nil, err + } + + var benchmarks []*BenchmarkData + for _, series := range seriesList { + values, err := c.QueryLatestTimeseriesValues(series) + + if err != nil { + return nil, err + } + + benchmarks = append(benchmarks, &BenchmarkData{ + Timeseries: series, + Values: values, + }) + } + + return &GetBenchmarksResult{ + Benchmarks: benchmarks, + }, nil +} diff --git a/db/db.go b/db/db.go index a429529ff9..4f99c719a2 100644 --- a/db/db.go +++ b/db/db.go @@ -644,6 +644,47 @@ func (c *Cocoon) SubmitTimeseriesValue(series *TimeseriesEntity, revision string return timeseriesValue, nil } +// QueryTimeseries returns all timeseries we have. +func (c *Cocoon) QueryTimeseries() ([]*TimeseriesEntity, error) { + query := datastore.NewQuery("Timeseries") + + var buffer []*TimeseriesEntity + for iter := query.Run(c.Ctx); ; { + var series Timeseries + key, err := iter.Next(&series) + if err == datastore.Done { + break + } else if err != nil { + return nil, err + } + + buffer = append(buffer, &TimeseriesEntity{ + key, + &series, + }) + } + return buffer, nil +} + +// QueryLatestTimeseriesValues fetches the latest benchmark results. +func (c *Cocoon) QueryLatestTimeseriesValues(series *TimeseriesEntity) ([]*TimeseriesValue, error) { + query := datastore.NewQuery("TimeseriesValue").Limit(50) + + var buffer []*TimeseriesValue + for iter := query.Run(c.Ctx); ; { + var value TimeseriesValue + _, err := iter.Next(&value) + if err == datastore.Done { + break + } else if err != nil { + return nil, err + } + + buffer = append(buffer, &value) + } + return buffer, nil +} + // FetchURL performs an HTTP GET request on the given URL and returns data if // response is HTTP 200. func (c *Cocoon) FetchURL(url string) ([]byte, error) {