From 7ef80f3430722421ded460347ed31494dda8b6b5 Mon Sep 17 00:00:00 2001 From: Joe Bowers Date: Tue, 16 Feb 2016 16:57:36 -0800 Subject: [PATCH] show sparkline-like charts next to resource metrics --- src/app/frontend/_variables.scss | 1 + .../common/components/components_module.js | 4 +- .../components/sparkline/sparkline.html | 23 ++++++ .../sparkline/sparkline_controller.js | 51 ++++++++++++ .../sparkline/sparkline_directive.js | 30 +++++++ .../replicationcontrollerdetail.html | 30 ++++--- .../replicationcontrollerdetail.scss | 42 ++++++++++ .../replicationcontrollerdetail_controller.js | 19 ++++- .../sparkline/sparkline_controller_test.js | 81 +++++++++++++++++++ ...icationcontrollerdetail_controller_test.js | 28 ++++--- 10 files changed, 282 insertions(+), 27 deletions(-) create mode 100644 src/app/frontend/common/components/sparkline/sparkline.html create mode 100644 src/app/frontend/common/components/sparkline/sparkline_controller.js create mode 100644 src/app/frontend/common/components/sparkline/sparkline_directive.js create mode 100644 src/test/frontend/common/components/sparkline/sparkline_controller_test.js diff --git a/src/app/frontend/_variables.scss b/src/app/frontend/_variables.scss index beacc0de3215..d1e1885cf0b3 100644 --- a/src/app/frontend/_variables.scss +++ b/src/app/frontend/_variables.scss @@ -38,6 +38,7 @@ $hover-secondary: #ff1c19; $body: #eee; $emphasis: #000; $content-background: #fff; +$chart-1: #00c752; // TODO(bryk): Get those variables from Angular Material scss files. $foreground-1: rgba(0, 0, 0, .87); diff --git a/src/app/frontend/common/components/components_module.js b/src/app/frontend/common/components/components_module.js index bb3f94d52569..e31490d70b8b 100644 --- a/src/app/frontend/common/components/components_module.js +++ b/src/app/frontend/common/components/components_module.js @@ -15,6 +15,7 @@ import filtersModule from '../filters/filters_module'; import labelsDirective from './labels/labels_directive'; import middleEllipsisDirective from './middleellipsis/middleellipsis_directive'; +import sparklineDirective from './sparkline/sparkline_directive'; /** * Module containing common components for the application. @@ -27,4 +28,5 @@ export default angular filtersModule.name, ]) .directive('kdLabels', labelsDirective) - .directive('kdMiddleEllipsis', middleEllipsisDirective); + .directive('kdMiddleEllipsis', middleEllipsisDirective) + .directive('kdSparkline', sparklineDirective); diff --git a/src/app/frontend/common/components/sparkline/sparkline.html b/src/app/frontend/common/components/sparkline/sparkline.html new file mode 100644 index 000000000000..f78f980bd900 --- /dev/null +++ b/src/app/frontend/common/components/sparkline/sparkline.html @@ -0,0 +1,23 @@ + + + + + diff --git a/src/app/frontend/common/components/sparkline/sparkline_controller.js b/src/app/frontend/common/components/sparkline/sparkline_controller.js new file mode 100644 index 000000000000..974a903b58a5 --- /dev/null +++ b/src/app/frontend/common/components/sparkline/sparkline_controller.js @@ -0,0 +1,51 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @final + */ +export default class SparklineController { + /** + * Constructs sparkline controller. + * @ngInject + */ + constructor() { + /** + * An array of {backendAPI.MetricResult} objects. The timestamp + * values of each object must be unique, and value must be greater + * than or equal to zero. + * @export {!Array} Initialized from the scope. + */ + this.timeseries; + } + + /** + * Formats the underlying series suitable for display as an SVG polygon. + * @return string + * @export + */ + polygonPoints() { + const series = this.timeseries.map(({timestamp, value}) => [Date.parse(timestamp), value]); + const sorted = series.slice().sort((a, b) => a[0] - b[0]); + const xShift = Math.min(...sorted.map((pt) => pt[0])); + const shifted = sorted.map(([x, y]) => [x - xShift, y]); + const xScale = Math.max(...shifted.map((pt) => pt[0])) || 1; + const yScale = Math.max(...shifted.map((pt) => pt[1])) || 1; + const scaled = shifted.map(([x, y]) => [x / xScale, y / yScale]); + + // Invert Y because SVG Y=0 is at the top, and we want low values + // of Y to be closer to the bottom of the graphic + return scaled.map(([x, y]) => `${x},${(1 - y)}`).join(' '); + } +} diff --git a/src/app/frontend/common/components/sparkline/sparkline_directive.js b/src/app/frontend/common/components/sparkline/sparkline_directive.js new file mode 100644 index 000000000000..5a230c786800 --- /dev/null +++ b/src/app/frontend/common/components/sparkline/sparkline_directive.js @@ -0,0 +1,30 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SparklineController from './sparkline_controller'; + +/** + * Returns directive definition for sparkline. + * @return {!angular.Directive} + */ +export default function sparklineDirective() { + return { + controller: SparklineController, + controllerAs: 'sparklineCtrl', + templateUrl: 'common/components/sparkline/sparkline.html', + templateNamespace: 'svg', + scope: {}, + bindToController: {'timeseries': '='}, + }; +} diff --git a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.html b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.html index b8ca17837b83..5f6f875de86f 100644 --- a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.html +++ b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.html @@ -135,21 +135,31 @@

- - - - {{::(pod.metrics.cpuUsage | kdCores)}} + + + + {{::(pod.metrics.cpuUsage | kdCores)}} + - + - - - - {{::(pod.metrics.memoryUsage | kdMemory)}} + + + + {{::(pod.metrics.memoryUsage | kdMemory)}} + - + - diff --git a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.scss b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.scss index 45f6fcc2b633..4ceeca6bcfd6 100644 --- a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.scss +++ b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail.scss @@ -117,3 +117,45 @@ md-icon { color: $delicate; font-size: $subhead-font-size-base; } + +.kd-replicationcontrollerdetail-labeled-sparkline-label { + white-space: nowrap; + word-spacing: normal; +} + +.kd-sparkline { + background-color: $body; + height: 2.5 * $baseline-grid; + vertical-align: -$baseline-grid / 2; // 20% below baseline, to flow with text + width: 12.5 * $baseline-grid; + + @media only screen and (min-width: $layout-breakpoint-lg) { + margin-left: $baseline-grid; + } + + @media only screen and (min-width: $layout-breakpoint-xs) and (max-width: $layout-breakpoint-lg) { + margin-top: $baseline-grid / 2; + } +} + +.kd-sparkline-series { + fill: $chart-1; +} + +.kd-replicationcontrollerdetail-labeled-sparkline { + @media only screen and (min-width: $layout-breakpoint-lg) { + display: block; + text-align: right; + white-space: nowrap; + } + + @media only screen and (min-width: $layout-breakpoint-xs) and (max-width: $layout-breakpoint-lg) { + display: block; + padding-bottom: $baseline-grid; + padding-top: $baseline-grid; + } + + @media only screen and (max-width: $layout-breakpoint-xs) { + word-spacing: $baseline-grid; + } +} diff --git a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller.js b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller.js index 35cdea3377fa..282f1d1e88a8 100644 --- a/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller.js +++ b/src/app/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller.js @@ -103,7 +103,7 @@ export default class ReplicationControllerDetailController { * @returns {boolean} * @export */ - isSidebarVisible() { return this.mdMedia('gt-sm'); } + isSidebarVisible() { return this.mdMedia('gt-md'); } /** * Returns true if event is a warning. @@ -183,7 +183,7 @@ export default class ReplicationControllerDetailController { * @export */ hasCpuUsage(pod) { - return !!pod.metrics && (!!pod.metrics.cpuUsage || pod.metrics.cpuUsage === 0); + return !!pod.metrics && !!pod.metrics.cpuUsageHistory && pod.metrics.cpuUsageHistory.length > 0; } /** @@ -192,6 +192,19 @@ export default class ReplicationControllerDetailController { * @export */ hasMemoryUsage(pod) { - return !!pod.metrics && (!!pod.metrics.memoryUsage || pod.metrics.memoryUsage === 0); + return !!pod.metrics && !!pod.metrics.memoryUsageHistory && + pod.metrics.memoryUsageHistory.length > 0; } + + /** + * Returns either 1 (if the table cells containing sparklines should + * shrink around their contents) or undefined (if those table cells + * should obey regular layout rules). The idiosyncratic return + * protocol is for compatibility with ng-attr's behavior - we want + * to generate either "width=1" or nothing at all. + * + * @return {(number|undefined)} + * @export + */ + shouldShrinkSparklineCells() { return (this.mdMedia('gt-xs') || undefined) && 1; } } diff --git a/src/test/frontend/common/components/sparkline/sparkline_controller_test.js b/src/test/frontend/common/components/sparkline/sparkline_controller_test.js new file mode 100644 index 000000000000..8d3185567c53 --- /dev/null +++ b/src/test/frontend/common/components/sparkline/sparkline_controller_test.js @@ -0,0 +1,81 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SparklineController from 'common/components/sparkline/sparkline_controller'; +import componentsModule from 'common/components/components_module'; + +describe('Sparkline controller', () => { + /** + * @type {!SparklineController} + */ + let ctrl; + + beforeEach(() => { + angular.mock.module(componentsModule.name); + + angular.mock.inject(($controller) => { ctrl = $controller(SparklineController); }); + }); + + it('should produce no points with an empty series', () => { + // given + ctrl.timeseries = []; + + // then + expect(ctrl.polygonPoints()).toBe(''); + }); + + it('should shift and scale times such that the minimum time is zero and the maximum time is 1', + () => { + // given + ctrl.timeseries = [ + {timestamp: '1976-01-15T00:00:00Z', value: 1}, + {timestamp: '2026-01-15T00:00:00Z', value: 1}, + ]; + + // then + expect(ctrl.polygonPoints()).toBe('0,0 1,0'); + }); + + it('should handle zero values and times without throwing an exception', () => { + // given + ctrl.timeseries = [ + {timestamp: '1970-01-01T00:00:00Z', value: 0}, + ]; + + // then + expect(ctrl.polygonPoints()).toBe('0,1'); + }); + + it('should scale values to <= 1 and invert them', () => { + // given + ctrl.timeseries = [ + {timestamp: '1976-01-15T10:00:00Z', value: 10}, + {timestamp: '1976-01-15T10:00:10Z', value: 1}, + ]; + + // then + expect(ctrl.polygonPoints()).toBe('0,0 1,0.9'); + }); + + it('should sort a time series by time', () => { + // given + ctrl.timeseries = [ + {timestamp: '1976-01-15T10:00:10Z', value: 1}, + {timestamp: '1976-01-15T10:00:00Z', value: 10}, + ]; + + // then + expect(ctrl.polygonPoints()).toBe('0,0 1,0.9'); + }); +}); diff --git a/src/test/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller_test.js b/src/test/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller_test.js index 0446bd3a511b..a68a8b2772fe 100644 --- a/src/test/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller_test.js +++ b/src/test/frontend/replicationcontrollerdetail/replicationcontrollerdetail_controller_test.js @@ -111,21 +111,23 @@ describe('Replication Controller Detail controller', () => { it('should show/hide cpu and memory metrics for pods', () => { expect(ctrl.hasMemoryUsage({})).toBe(false); expect(ctrl.hasMemoryUsage({metrics: {}})).toBe(false); - expect(ctrl.hasMemoryUsage({metrics: {memoryUsage: 0}})).toBe(true); - expect(ctrl.hasMemoryUsage({metrics: {memoryUsage: 1}})).toBe(true); - expect(ctrl.hasMemoryUsage({metrics: {memoryUsage: null}})).toBe(false); - expect(ctrl.hasMemoryUsage({metrics: {memoryUsage: undefined}})).toBe(false); - expect(ctrl.hasMemoryUsage({metrics: {cpuUsage: 1}})).toBe(false); + expect(ctrl.hasMemoryUsage({metrics: {memoryUsageHistory: []}})).toBe(false); + expect(ctrl.hasMemoryUsage({metrics: {memoryUsageHistory: [0]}})).toBe(true); + expect(ctrl.hasMemoryUsage({metrics: {memoryUsageHistory: [1]}})).toBe(true); + expect(ctrl.hasMemoryUsage({metrics: {memoryUsageHistory: null}})).toBe(false); + expect(ctrl.hasMemoryUsage({metrics: {memoryUsageHistory: undefined}})).toBe(false); + expect(ctrl.hasMemoryUsage({metrics: {cpuUsageHistory: [1]}})).toBe(false); expect(ctrl.hasCpuUsage({})).toBe(false); expect(ctrl.hasCpuUsage({metrics: {}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {memoryUsage: 0}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {memoryUsage: 1}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {memoryUsage: null}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {memoryUsage: undefined}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {cpuUsage: 1}})).toBe(true); - expect(ctrl.hasCpuUsage({metrics: {cpuUsage: 0}})).toBe(true); - expect(ctrl.hasCpuUsage({metrics: {cpuUsage: null}})).toBe(false); - expect(ctrl.hasCpuUsage({metrics: {cpuUsage: undefined}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {memoryUsageHistory: []}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {memoryUsageHistory: [1]}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {memoryUsageHistory: null}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {memoryUsageHistory: undefined}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {cpuUsageHistory: [1]}})).toBe(true); + expect(ctrl.hasCpuUsage({metrics: {cpuUsageHistory: [0]}})).toBe(true); + expect(ctrl.hasCpuUsage({metrics: {cpuUsageHistory: []}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {cpuUsageHistory: null}})).toBe(false); + expect(ctrl.hasCpuUsage({metrics: {cpuUsageHistory: undefined}})).toBe(false); }); });