Merge pull request #7911 from hashicorp/f-ui/csi-availability-gauge
UI: CSI Availability Gauges
This commit is contained in:
commit
eb890eac2e
|
@ -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 ratio;
|
||||||
let total = options.total;
|
let total = options.total;
|
||||||
|
|
||||||
if (total !== undefined) {
|
if (total != undefined) {
|
||||||
total = safeNumber(total);
|
total = safeNumber(total);
|
||||||
} else if (complement !== undefined) {
|
} else if (complement != undefined) {
|
||||||
total = value + safeNumber(complement);
|
total = value + safeNumber(complement);
|
||||||
} else {
|
} else {
|
||||||
// Ensures that ratio is between 0 and 1 when neither total or complement are defined
|
// 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/distribution-bar';
|
||||||
|
@import './charts/gauge-chart';
|
||||||
@import './charts/line-chart';
|
@import './charts/line-chart';
|
||||||
@import './charts/tooltip';
|
@import './charts/tooltip';
|
||||||
@import './charts/colors';
|
@import './charts/colors';
|
||||||
|
|
|
@ -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 {
|
.metric {
|
||||||
padding: 0.75em 1em;
|
padding: 0.75em 1em;
|
||||||
border: 1px solid $grey-blue;
|
border: 1px solid $grey-blue;
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
@ -50,15 +49,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
&.is-hollow {
|
||||||
font-size: 1.1em;
|
border-color: transparent;
|
||||||
font-weight: $weight-semibold;
|
background: transparent;
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,4 +10,8 @@
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-bottom-aligned {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: .8rem;
|
font-size: 0.8rem;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,4 +116,30 @@
|
||||||
margin: 0;
|
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-200: #dce0e6;
|
||||||
$ui-gray-300: #bac1cc;
|
$ui-gray-300: #bac1cc;
|
||||||
$ui-gray-400: #8e96a3;
|
$ui-gray-400: #8e96a3;
|
||||||
|
|
|
@ -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>
|
</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">
|
||||||
<div class="boxed-section-head">
|
<div class="boxed-section-head">
|
||||||
Controller Allocations
|
Controller Allocations
|
||||||
|
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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]'),
|
||||||
|
});
|
|
@ -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 New Issue