Merge pull request #7911 from hashicorp/f-ui/csi-availability-gauge

UI: CSI Availability Gauges
This commit is contained in:
Michael Lange 2020-05-13 10:18:17 -07:00 committed by GitHub
commit eb890eac2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 472 additions and 13 deletions

View 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);
},
});

View file

@ -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

View file

@ -1,4 +1,5 @@
@import './charts/distribution-bar';
@import './charts/gauge-chart';
@import './charts/line-chart';
@import './charts/tooltip';
@import './charts/colors';

View 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%;
}
}

View file

@ -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;
}
}

View file

@ -10,4 +10,8 @@
flex-grow: 0;
}
}
&.is-bottom-aligned {
align-items: flex-end;
}
}

View file

@ -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;
}
}
}

View file

@ -1,3 +1,4 @@
$ui-gray-100: #ebeef2;
$ui-gray-200: #dce0e6;
$ui-gray-300: #bac1cc;
$ui-gray-400: #8e96a3;

View 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

View file

@ -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

View 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(),
},
};
};

View 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);
});
});

View 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]'),
});

View 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);
});
});