diff --git a/ui/app/components/gauge-chart.js b/ui/app/components/gauge-chart.js new file mode 100644 index 000000000000..2f30a2f3d198 --- /dev/null +++ b/ui/app/components/gauge-chart.js @@ -0,0 +1,86 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { assert } from '@ember/debug'; +import { guidFor } from '@ember/object/internals'; +import { run } from '@ember/runloop'; +import d3Shape from 'd3-shape'; +import WindowResizable from 'nomad-ui/mixins/window-resizable'; + +export default Component.extend(WindowResizable, { + classNames: ['chart', 'gauge-chart'], + + value: null, + complement: null, + total: null, + chartClass: 'is-info', + + width: 0, + height: 0, + + percent: computed('value', 'complement', 'total', function() { + assert( + 'Provide complement OR total to GaugeChart, not both.', + this.complement != null || this.total != null + ); + + if (this.complement != null) { + return this.value / (this.value + this.complement); + } + + return this.value / this.total; + }), + + fillId: computed(function() { + return `gauge-chart-fill-${guidFor(this)}`; + }), + + maskId: computed(function() { + return `gauge-chart-mask-${guidFor(this)}`; + }), + + radius: computed('width', function() { + return this.width / 2; + }), + + weight: 4, + + backgroundArc: computed('radius', 'weight', function() { + const { radius, weight } = this; + const arc = d3Shape + .arc() + .outerRadius(radius) + .innerRadius(radius - weight) + .cornerRadius(weight) + .startAngle(-Math.PI / 2) + .endAngle(Math.PI / 2); + return arc(); + }), + + valueArc: computed('radius', 'weight', 'percent', function() { + const { radius, weight, percent } = this; + + const arc = d3Shape + .arc() + .outerRadius(radius) + .innerRadius(radius - weight) + .cornerRadius(weight) + .startAngle(-Math.PI / 2) + .endAngle(-Math.PI / 2 + Math.PI * percent); + return arc(); + }), + + didInsertElement() { + this.updateDimensions(); + }, + + updateDimensions() { + const $svg = this.$('svg'); + const width = $svg.width(); + + this.setProperties({ width, height: width / 2 }); + }, + + windowResizeHandler() { + run.once(this, this.updateDimensions); + }, +}); diff --git a/ui/app/helpers/format-percentage.js b/ui/app/helpers/format-percentage.js index cfd409c13f48..9bd2b6f0dfdd 100644 --- a/ui/app/helpers/format-percentage.js +++ b/ui/app/helpers/format-percentage.js @@ -15,9 +15,9 @@ export function formatPercentage(params, options = {}) { let ratio; let total = options.total; - if (total !== undefined) { + if (total != undefined) { total = safeNumber(total); - } else if (complement !== undefined) { + } else if (complement != undefined) { total = value + safeNumber(complement); } else { // Ensures that ratio is between 0 and 1 when neither total or complement are defined diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index 571535ca5ea7..9d7d4a47ed38 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,4 +1,5 @@ @import './charts/distribution-bar'; +@import './charts/gauge-chart'; @import './charts/line-chart'; @import './charts/tooltip'; @import './charts/colors'; diff --git a/ui/app/styles/charts/gauge-chart.scss b/ui/app/styles/charts/gauge-chart.scss new file mode 100644 index 000000000000..f82b82c0e91a --- /dev/null +++ b/ui/app/styles/charts/gauge-chart.scss @@ -0,0 +1,52 @@ +.gauge-chart { + position: relative; + display: block; + width: auto; + + svg { + display: block; + margin: auto; + width: 100%; + max-width: 200px; + height: 100%; + } + + .background, + .fill { + transform: translate(50%, 100%); + } + + .background { + fill: $ui-gray-100; + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + .canvas.is-#{$name} { + .line { + stroke: $color; + } + } + + linearGradient { + &.is-#{$name} { + > .start { + stop-color: $color; + stop-opacity: 0.2; + } + + > .end { + stop-color: $color; + stop-opacity: 1; + } + } + } + } + + .metric { + position: absolute; + bottom: 0; + width: 100%; + } +} diff --git a/ui/app/styles/components/metrics.scss b/ui/app/styles/components/metrics.scss index 0f94e3fa1c75..66f9bf2866ff 100644 --- a/ui/app/styles/components/metrics.scss +++ b/ui/app/styles/components/metrics.scss @@ -6,7 +6,6 @@ .metric { padding: 0.75em 1em; border: 1px solid $grey-blue; - text-align: center; display: flex; flex-direction: column; min-width: 120px; @@ -50,15 +49,25 @@ } } - .label { - font-size: 1.1em; - font-weight: $weight-semibold; - margin-bottom: 0; + &.is-hollow { + border-color: transparent; + background: transparent; } + } +} - .value { - font-size: 2em; - margin-bottom: 0; - } +.metric { + text-align: center; + + .label { + font-size: 1.1em; + font-weight: $weight-semibold; + margin-bottom: 0; + } + + .value { + font-size: 2em; + margin-bottom: 0; + line-height: 1; } } diff --git a/ui/app/styles/core/columns.scss b/ui/app/styles/core/columns.scss index 9105950476f6..d68425e9a88a 100644 --- a/ui/app/styles/core/columns.scss +++ b/ui/app/styles/core/columns.scss @@ -10,4 +10,8 @@ flex-grow: 0; } } + + &.is-bottom-aligned { + align-items: flex-end; + } } diff --git a/ui/app/styles/storybook.scss b/ui/app/styles/storybook.scss index d357a833c99c..bc37fe4265f8 100644 --- a/ui/app/styles/storybook.scss +++ b/ui/app/styles/storybook.scss @@ -66,7 +66,7 @@ } .description { - font-size: .8rem; + font-size: 0.8rem; padding-bottom: 5px; } @@ -116,4 +116,30 @@ margin: 0; } } + + .multiples { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + } + + .chart-container { + width: 200px; + padding: 15px; + border: 1px solid $ui-gray-200; + display: inline-block; + + &.is-small { + width: 150px; + } + + &.is-large { + width: 250px; + } + + &.is-xlarge { + width: 300px; + } + } } diff --git a/ui/app/styles/utils/structure-colors.scss b/ui/app/styles/utils/structure-colors.scss index 6ef72ebdab38..70d320a888b2 100644 --- a/ui/app/styles/utils/structure-colors.scss +++ b/ui/app/styles/utils/structure-colors.scss @@ -1,3 +1,4 @@ +$ui-gray-100: #ebeef2; $ui-gray-200: #dce0e6; $ui-gray-300: #bac1cc; $ui-gray-400: #8e96a3; diff --git a/ui/app/templates/components/gauge-chart.hbs b/ui/app/templates/components/gauge-chart.hbs new file mode 100644 index 000000000000..38bb19137622 --- /dev/null +++ b/ui/app/templates/components/gauge-chart.hbs @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + +
+

{{label}}

+

{{format-percentage value total=total complement=complement}}

+
diff --git a/ui/app/templates/csi/plugins/plugin.hbs b/ui/app/templates/csi/plugins/plugin.hbs index e777a11e9833..c189e8d4e186 100644 --- a/ui/app/templates/csi/plugins/plugin.hbs +++ b/ui/app/templates/csi/plugins/plugin.hbs @@ -22,6 +22,63 @@ +
+
+
+
Controller Health
+
+
+
+ {{gauge-chart + label="Availability" + value=model.controllersHealthy + total=model.controllersExpected}} +
+
+
+

Available

+

{{model.controllersHealthy}}

+
+
+
+
+

Expected

+

{{model.controllersExpected}}

+
+
+
+
+
+
+
+
+
Node Health
+
+
+
+ {{gauge-chart + label="Availability" + value=model.nodesHealthy + total=model.nodesExpected}} +
+
+
+

Available

+

{{model.nodesHealthy}}

+
+
+
+
+

Expected

+

{{model.nodesExpected}}

+
+
+
+
+
+
+
+
Controller Allocations diff --git a/ui/stories/charts/gauge-chart.stories.js b/ui/stories/charts/gauge-chart.stories.js new file mode 100644 index 000000000000..b902b3ba5709 --- /dev/null +++ b/ui/stories/charts/gauge-chart.stories.js @@ -0,0 +1,115 @@ +import hbs from 'htmlbars-inline-precompile'; +import DelayedArray from '../utils/delayed-array'; +import DelayedTruth from '../utils/delayed-truth'; + +export default { + title: 'Charts|Gauge Chart', +}; + +let totalVariations = [ + { value: 0, total: 10 }, + { value: 1, total: 10 }, + { value: 2, total: 10 }, + { value: 3, total: 10 }, + { value: 4, total: 10 }, + { value: 5, total: 10 }, + { value: 6, total: 10 }, + { value: 7, total: 10 }, + { value: 8, total: 10 }, + { value: 9, total: 10 }, + { value: 10, total: 10 }, +]; + +let complementVariations = [ + { value: 0, complement: 10 }, + { value: 1, complement: 9 }, + { value: 2, complement: 8 }, + { value: 3, complement: 7 }, + { value: 4, complement: 6 }, + { value: 5, complement: 5 }, + { value: 6, complement: 4 }, + { value: 7, complement: 3 }, + { value: 8, complement: 2 }, + { value: 9, complement: 1 }, + { value: 10, complement: 0 }, +]; + +let colorVariations = ['is-info', 'is-warning', 'is-success', 'is-danger']; + +export let Total = () => { + return { + template: hbs` +
+ {{#each variations as |v|}} +
+ {{gauge-chart value=v.value total=v.total label="Total" chartClass="is-info"}} +
+ {{/each}} +
+ `, + context: { + variations: DelayedArray.create(totalVariations), + }, + }; +}; + +export let Complement = () => { + return { + template: hbs` +
+ {{#each variations as |v|}} +
+ {{gauge-chart value=v.value complement=v.complement label="Complement" chartClass="is-info"}} +
+ {{/each}} +
+ `, + context: { + variations: DelayedArray.create(complementVariations), + }, + }; +}; + +export let Colors = () => { + return { + template: hbs` +
+ {{#each variations as |color|}} +
+ {{gauge-chart value=7 total=10 label=color chartClass=color}} +
+ {{/each}} +
+ `, + context: { + variations: DelayedArray.create(colorVariations), + }, + }; +}; + +export let Sizing = () => { + return { + template: hbs` + {{#if delayedTruth.complete}} +
+
+ {{gauge-chart value=7 total=10 label="Small"}} +
+
+ {{gauge-chart value=7 total=10 label="Regular"}} +
+
+ {{gauge-chart value=7 total=10 label="Large"}} +
+
+ {{gauge-chart value=7 total=10 label="X-Large"}} +
+
+ {{/if}} +

GaugeCharts fill the width of their container and have a dynamic height according to the height of the arc. However, the text within a gauge chart is fixed. This can create unsightly overlap or whitespace, so be careful about responsiveness when using this chart type.

+ `, + context: { + delayedTruth: DelayedTruth.create(), + }, + }; +}; diff --git a/ui/tests/integration/gauge-chart-test.js b/ui/tests/integration/gauge-chart-test.js new file mode 100644 index 000000000000..aa3ddd06a826 --- /dev/null +++ b/ui/tests/integration/gauge-chart-test.js @@ -0,0 +1,53 @@ +import { find, render } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { create } from 'ember-cli-page-object'; +import gaugeChart from 'nomad-ui/tests/pages/components/gauge-chart'; + +const GaugeChart = create(gaugeChart()); + +module('Integration | Component | gauge chart', function(hooks) { + setupRenderingTest(hooks); + + const commonProperties = () => ({ + value: 5, + total: 10, + label: 'Gauge', + }); + + test('presents as an svg, a formatted percentage, and a label', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + + await render(hbs` + {{gauge-chart + value=value + total=total + label=label}} + `); + + assert.equal(GaugeChart.label, props.label); + assert.equal(GaugeChart.percentage, '50%'); + assert.ok(GaugeChart.svgIsPresent); + }); + + test('the width of the chart is determined based on the container and the height is a function of the width', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + + await render(hbs` +
+ {{gauge-chart + value=value + total=total + label=label}} +
+ `); + + const svg = find('[data-test-gauge-svg]'); + + assert.equal(window.getComputedStyle(svg).width, '100px'); + assert.equal(svg.getAttribute('height'), 50); + }); +}); diff --git a/ui/tests/pages/components/gauge-chart.js b/ui/tests/pages/components/gauge-chart.js new file mode 100644 index 000000000000..04b59287c189 --- /dev/null +++ b/ui/tests/pages/components/gauge-chart.js @@ -0,0 +1,9 @@ +import { isPresent, text } from 'ember-cli-page-object'; + +export default scope => ({ + scope, + + svgIsPresent: isPresent('[data-test-gauge-svg]'), + label: text('[data-test-label]'), + percentage: text('[data-test-percentage]'), +}); diff --git a/ui/tests/unit/components/gauge-chart-test.js b/ui/tests/unit/components/gauge-chart-test.js new file mode 100644 index 000000000000..771da46a1142 --- /dev/null +++ b/ui/tests/unit/components/gauge-chart-test.js @@ -0,0 +1,27 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Component | gauge-chart', function(hooks) { + setupTest(hooks); + + hooks.beforeEach(function() { + this.subject = this.owner.factoryFor('component:gauge-chart'); + }); + + test('percent is a function of value and total OR complement', function(assert) { + const chart = this.subject.create(); + chart.setProperties({ + value: 5, + total: 10, + }); + + assert.equal(chart.percent, 0.5); + + chart.setProperties({ + total: null, + complement: 15, + }); + + assert.equal(chart.percent, 0.25); + }); +});