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
+
+
+
+
+
+
+
+
+
+
+
+ Sign out of Google
+ Sign in with Google
+
+
+
+
+
+
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 @@
Sign in with Google
-
+