Merge pull request #7911 from hashicorp/f-ui/csi-availability-gauge
UI: CSI Availability Gauges
This commit is contained in:
commit
eb890eac2e
86
ui/app/components/gauge-chart.js
Normal file
86
ui/app/components/gauge-chart.js
Normal file
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import './charts/distribution-bar';
|
||||
@import './charts/gauge-chart';
|
||||
@import './charts/line-chart';
|
||||
@import './charts/tooltip';
|
||||
@import './charts/colors';
|
||||
|
|
52
ui/app/styles/charts/gauge-chart.scss
Normal file
52
ui/app/styles/charts/gauge-chart.scss
Normal file
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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,6 +49,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.is-hollow {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
|
||||
.label {
|
||||
font-size: 1.1em;
|
||||
font-weight: $weight-semibold;
|
||||
|
@ -59,6 +68,6 @@
|
|||
.value {
|
||||
font-size: 2em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,4 +10,8 @@
|
|||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-bottom-aligned {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
$ui-gray-100: #ebeef2;
|
||||
$ui-gray-200: #dce0e6;
|
||||
$ui-gray-300: #bac1cc;
|
||||
$ui-gray-400: #8e96a3;
|
||||
|
|
19
ui/app/templates/components/gauge-chart.hbs
Normal file
19
ui/app/templates/components/gauge-chart.hbs
Normal file
|
@ -0,0 +1,19 @@
|
|||
<svg data-test-gauge-svg role="img" height={{height}}>
|
||||
<defs>
|
||||
<linearGradient x1="0" x2="1" y1="0" y2="0" class="{{chartClass}}" id="{{fillId}}">
|
||||
<stop class="start" offset="0%" />
|
||||
<stop class="end" offset="100%" />
|
||||
</linearGradient>
|
||||
<clipPath id="{{maskId}}">
|
||||
<path class="fill" d="{{valueArc}}" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g class="canvas {{chartClass}}">
|
||||
<path class="background" d="{{backgroundArc}}" />
|
||||
<rect class="area" x="0" y="0" width="100%" height="100%" fill="url(#{{fillId}})" clip-path="url(#{{maskId}})" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="metric">
|
||||
<h3 data-test-label class="label">{{label}}</h3>
|
||||
<p data-test-percentage class="value">{{format-percentage value total=total complement=complement}}</p>
|
||||
</div>
|
After Width: | Height: | Size: 765 B |
|
@ -22,6 +22,63 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head is-hollow">Controller Health</div>
|
||||
<div class="boxed-section-body">
|
||||
<div class="columns is-bottom-aligned">
|
||||
<div class="column is-half">
|
||||
{{gauge-chart
|
||||
label="Availability"
|
||||
value=model.controllersHealthy
|
||||
total=model.controllersExpected}}
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="metric">
|
||||
<h3 class="label">Available</h3>
|
||||
<p class="value">{{model.controllersHealthy}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="metric">
|
||||
<h3 class="label">Expected</h3>
|
||||
<p class="value">{{model.controllersExpected}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head is-hollow">Node Health</div>
|
||||
<div class="boxed-section-body">
|
||||
<div class="columns is-bottom-aligned">
|
||||
<div class="column is-half">
|
||||
{{gauge-chart
|
||||
label="Availability"
|
||||
value=model.nodesHealthy
|
||||
total=model.nodesExpected}}
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="metric">
|
||||
<h3 class="label">Available</h3>
|
||||
<p class="value">{{model.nodesHealthy}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="metric">
|
||||
<h3 class="label">Expected</h3>
|
||||
<p class="value">{{model.nodesExpected}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
Controller Allocations
|
||||
|
|
115
ui/stories/charts/gauge-chart.stories.js
Normal file
115
ui/stories/charts/gauge-chart.stories.js
Normal file
|
@ -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`
|
||||
<div class="multiples">
|
||||
{{#each variations as |v|}}
|
||||
<div class="chart-container">
|
||||
{{gauge-chart value=v.value total=v.total label="Total" chartClass="is-info"}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
variations: DelayedArray.create(totalVariations),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let Complement = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<div class="multiples">
|
||||
{{#each variations as |v|}}
|
||||
<div class="chart-container">
|
||||
{{gauge-chart value=v.value complement=v.complement label="Complement" chartClass="is-info"}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
variations: DelayedArray.create(complementVariations),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let Colors = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
<div class="multiples">
|
||||
{{#each variations as |color|}}
|
||||
<div class="chart-container">
|
||||
{{gauge-chart value=7 total=10 label=color chartClass=color}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
variations: DelayedArray.create(colorVariations),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export let Sizing = () => {
|
||||
return {
|
||||
template: hbs`
|
||||
{{#if delayedTruth.complete}}
|
||||
<div class="multiples">
|
||||
<div class="chart-container is-small">
|
||||
{{gauge-chart value=7 total=10 label="Small"}}
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
{{gauge-chart value=7 total=10 label="Regular"}}
|
||||
</div>
|
||||
<div class="chart-container is-large">
|
||||
{{gauge-chart value=7 total=10 label="Large"}}
|
||||
</div>
|
||||
<div class="chart-container is-xlarge">
|
||||
{{gauge-chart value=7 total=10 label="X-Large"}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p class="annotation">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.</p>
|
||||
`,
|
||||
context: {
|
||||
delayedTruth: DelayedTruth.create(),
|
||||
},
|
||||
};
|
||||
};
|
53
ui/tests/integration/gauge-chart-test.js
Normal file
53
ui/tests/integration/gauge-chart-test.js
Normal file
|
@ -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`
|
||||
<div style="width:100px">
|
||||
{{gauge-chart
|
||||
value=value
|
||||
total=total
|
||||
label=label}}
|
||||
</div>
|
||||
`);
|
||||
|
||||
const svg = find('[data-test-gauge-svg]');
|
||||
|
||||
assert.equal(window.getComputedStyle(svg).width, '100px');
|
||||
assert.equal(svg.getAttribute('height'), 50);
|
||||
});
|
||||
});
|
9
ui/tests/pages/components/gauge-chart.js
Normal file
9
ui/tests/pages/components/gauge-chart.js
Normal file
|
@ -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]'),
|
||||
});
|
27
ui/tests/unit/components/gauge-chart-test.js
Normal file
27
ui/tests/unit/components/gauge-chart-test.js
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue