diff --git a/ui/app/components/gauge-chart.js b/ui/app/components/gauge-chart.js new file mode 100644 index 00000000000..2f30a2f3d19 --- /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 cfd409c13f4..9bd2b6f0dfd 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 571535ca5ea..9d7d4a47ed3 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 00000000000..f82b82c0e91 --- /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 0f94e3fa1c7..66f9bf2866f 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 9105950476f..d68425e9a88 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 d357a833c99..bc37fe4265f 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 6ef72ebdab3..70d320a888b 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 00000000000..38bb1913762 --- /dev/null +++ b/ui/app/templates/components/gauge-chart.hbs @@ -0,0 +1,19 @@ + +
{{format-percentage value total=total complement=complement}}
+{{model.controllersHealthy}}
+{{model.controllersExpected}}
+{{model.nodesHealthy}}
+{{model.nodesExpected}}
+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 00000000000..aa3ddd06a82 --- /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` +