387 lines
8.3 KiB
JavaScript
387 lines
8.3 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
|
|
import Component from '@glimmer/component';
|
|
import { action } from '@ember/object';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { next } from '@ember/runloop';
|
|
import { htmlSafe } from '@ember/string';
|
|
import { get } from '@ember/object';
|
|
|
|
import { scaleLinear } from 'd3-scale';
|
|
import d3Format from 'd3-format';
|
|
|
|
const statsKeyToLabel = {
|
|
min: 'Min',
|
|
median: 'Median',
|
|
mean: 'Mean',
|
|
p99: '99th',
|
|
max: 'Max',
|
|
current: 'Current',
|
|
recommended: 'New',
|
|
};
|
|
|
|
const formatPercent = d3Format.format('+.0%');
|
|
export default class RecommendationChartComponent extends Component {
|
|
@tracked width;
|
|
@tracked height;
|
|
|
|
@tracked shown = false;
|
|
|
|
@tracked showLegend = false;
|
|
@tracked mouseX;
|
|
@tracked activeLegendRow;
|
|
|
|
get isIncrease() {
|
|
return this.args.currentValue < this.args.recommendedValue;
|
|
}
|
|
|
|
get directionClass() {
|
|
if (this.args.disabled) {
|
|
return 'disabled';
|
|
} else if (this.isIncrease) {
|
|
return 'increase';
|
|
} else {
|
|
return 'decrease';
|
|
}
|
|
}
|
|
|
|
get icon() {
|
|
return {
|
|
x: 0,
|
|
y: this.resourceLabel.y - this.iconHeight / 2,
|
|
width: 20,
|
|
height: this.iconHeight,
|
|
name: this.isIncrease ? 'arrow-up' : 'arrow-down',
|
|
};
|
|
}
|
|
|
|
gutterWidthLeft = 62;
|
|
gutterWidthRight = 50;
|
|
gutterWidth = this.gutterWidthLeft + this.gutterWidthRight;
|
|
|
|
iconHeight = 21;
|
|
|
|
tickTextHeight = 15;
|
|
|
|
edgeTickHeight = 23;
|
|
centerTickOffset = 6;
|
|
|
|
centerY =
|
|
this.tickTextHeight + this.centerTickOffset + this.edgeTickHeight / 2;
|
|
|
|
edgeTickY1 = this.tickTextHeight + this.centerTickOffset;
|
|
edgeTickY2 =
|
|
this.tickTextHeight + this.edgeTickHeight + this.centerTickOffset;
|
|
|
|
deltaTextY = this.edgeTickY2;
|
|
|
|
meanHeight = this.edgeTickHeight * 0.6;
|
|
p99Height = this.edgeTickHeight * 0.48;
|
|
maxHeight = this.edgeTickHeight * 0.4;
|
|
|
|
deltaTriangleHeight = this.edgeTickHeight / 2.5;
|
|
|
|
get statsShapes() {
|
|
if (this.width) {
|
|
const maxShapes = this.shapesFor('max');
|
|
const p99Shapes = this.shapesFor('p99');
|
|
const meanShapes = this.shapesFor('mean');
|
|
|
|
const labelProximityThreshold = 25;
|
|
|
|
if (p99Shapes.text.x + labelProximityThreshold > maxShapes.text.x) {
|
|
maxShapes.text.class = 'right';
|
|
}
|
|
|
|
if (meanShapes.text.x + labelProximityThreshold > p99Shapes.text.x) {
|
|
p99Shapes.text.class = 'right';
|
|
}
|
|
|
|
if (meanShapes.text.x + labelProximityThreshold * 2 > maxShapes.text.x) {
|
|
p99Shapes.text.class = 'hidden';
|
|
}
|
|
|
|
return [maxShapes, p99Shapes, meanShapes];
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
shapesFor(key) {
|
|
const stat = this.args.stats[key];
|
|
|
|
const rectWidth = this.xScale(stat);
|
|
const rectHeight = this[`${key}Height`];
|
|
|
|
const tickX = rectWidth + this.gutterWidthLeft;
|
|
|
|
const label = statsKeyToLabel[key];
|
|
|
|
return {
|
|
class: key,
|
|
text: {
|
|
label,
|
|
x: tickX,
|
|
y: this.tickTextHeight - 5,
|
|
class: '', // overridden in statsShapes to align/hide based on proximity
|
|
},
|
|
line: {
|
|
x1: tickX,
|
|
y1: this.tickTextHeight,
|
|
x2: tickX,
|
|
y2: this.centerY - 2,
|
|
},
|
|
rect: {
|
|
x: this.gutterWidthLeft,
|
|
y:
|
|
(this.edgeTickHeight - rectHeight) / 2 +
|
|
this.centerTickOffset +
|
|
this.tickTextHeight,
|
|
width: rectWidth,
|
|
height: rectHeight,
|
|
},
|
|
};
|
|
}
|
|
|
|
get barWidth() {
|
|
return this.width - this.gutterWidth;
|
|
}
|
|
|
|
get higherValue() {
|
|
return Math.max(this.args.currentValue, this.args.recommendedValue);
|
|
}
|
|
|
|
get maximumX() {
|
|
return Math.max(
|
|
this.higherValue,
|
|
get(this.args.stats, 'max') || Number.MIN_SAFE_INTEGER
|
|
);
|
|
}
|
|
|
|
get lowerValue() {
|
|
return Math.min(this.args.currentValue, this.args.recommendedValue);
|
|
}
|
|
|
|
get xScale() {
|
|
return scaleLinear()
|
|
.domain([0, this.maximumX])
|
|
.rangeRound([0, this.barWidth]);
|
|
}
|
|
|
|
get lowerValueWidth() {
|
|
return this.gutterWidthLeft + this.xScale(this.lowerValue);
|
|
}
|
|
|
|
get higherValueWidth() {
|
|
return this.gutterWidthLeft + this.xScale(this.higherValue);
|
|
}
|
|
|
|
get center() {
|
|
if (this.width) {
|
|
return {
|
|
x1: this.gutterWidthLeft,
|
|
y1: this.centerY,
|
|
x2: this.width - this.gutterWidthRight,
|
|
y2: this.centerY,
|
|
};
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
get resourceLabel() {
|
|
const text = this.args.resource === 'CPU' ? 'CPU' : 'Mem';
|
|
|
|
return {
|
|
text,
|
|
x: this.gutterWidthLeft - 10,
|
|
y: this.centerY,
|
|
};
|
|
}
|
|
|
|
get deltaRect() {
|
|
if (this.isIncrease) {
|
|
return {
|
|
x: this.lowerValueWidth,
|
|
y: this.edgeTickY1,
|
|
width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
|
|
height: this.edgeTickHeight,
|
|
};
|
|
} else {
|
|
return {
|
|
x: this.shown ? this.lowerValueWidth : this.higherValueWidth,
|
|
y: this.edgeTickY1,
|
|
width: this.shown ? this.higherValueWidth - this.lowerValueWidth : 0,
|
|
height: this.edgeTickHeight,
|
|
};
|
|
}
|
|
}
|
|
|
|
get deltaTriangle() {
|
|
const directionXMultiplier = this.isIncrease ? 1 : -1;
|
|
let translateX;
|
|
|
|
if (this.shown) {
|
|
translateX = this.isIncrease
|
|
? this.higherValueWidth
|
|
: this.lowerValueWidth;
|
|
} else {
|
|
translateX = this.isIncrease
|
|
? this.lowerValueWidth
|
|
: this.higherValueWidth;
|
|
}
|
|
|
|
return {
|
|
style: htmlSafe(`transform: translateX(${translateX}px)`),
|
|
points: `
|
|
0,${this.center.y1}
|
|
0,${this.center.y1 - this.deltaTriangleHeight / 2}
|
|
${(directionXMultiplier * this.deltaTriangleHeight) / 2},${
|
|
this.center.y1
|
|
}
|
|
0,${this.center.y1 + this.deltaTriangleHeight / 2}
|
|
`,
|
|
};
|
|
}
|
|
|
|
get deltaLines() {
|
|
if (this.isIncrease) {
|
|
return {
|
|
original: {
|
|
x: this.lowerValueWidth,
|
|
},
|
|
delta: {
|
|
style: htmlSafe(
|
|
`transform: translateX(${
|
|
this.shown ? this.higherValueWidth : this.lowerValueWidth
|
|
}px)`
|
|
),
|
|
},
|
|
};
|
|
} else {
|
|
return {
|
|
original: {
|
|
x: this.higherValueWidth,
|
|
},
|
|
delta: {
|
|
style: htmlSafe(
|
|
`transform: translateX(${
|
|
this.shown ? this.lowerValueWidth : this.higherValueWidth
|
|
}px)`
|
|
),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
get deltaText() {
|
|
const yOffset = 17;
|
|
const y = this.deltaTextY + yOffset;
|
|
|
|
const lowerValueText = {
|
|
anchor: 'end',
|
|
x: this.lowerValueWidth,
|
|
y,
|
|
};
|
|
|
|
const higherValueText = {
|
|
anchor: 'start',
|
|
x: this.higherValueWidth,
|
|
y,
|
|
};
|
|
|
|
const percentText = formatPercent(
|
|
(this.args.recommendedValue - this.args.currentValue) /
|
|
this.args.currentValue
|
|
);
|
|
|
|
const percent = {
|
|
x: (lowerValueText.x + higherValueText.x) / 2,
|
|
y,
|
|
text: percentText,
|
|
};
|
|
|
|
if (this.isIncrease) {
|
|
return {
|
|
original: lowerValueText,
|
|
delta: higherValueText,
|
|
percent,
|
|
};
|
|
} else {
|
|
return {
|
|
original: higherValueText,
|
|
delta: lowerValueText,
|
|
percent,
|
|
};
|
|
}
|
|
}
|
|
|
|
get chartHeight() {
|
|
return this.deltaText.original.y + 1;
|
|
}
|
|
|
|
get tooltipStyle() {
|
|
if (this.showLegend) {
|
|
return htmlSafe(`left: ${this.mouseX}px`);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
get sortedStats() {
|
|
if (this.args.stats) {
|
|
const statsWithCurrentAndRecommended = {
|
|
...this.args.stats,
|
|
current: this.args.currentValue,
|
|
recommended: this.args.recommendedValue,
|
|
};
|
|
|
|
return Object.keys(statsWithCurrentAndRecommended)
|
|
.map((key) => ({
|
|
label: statsKeyToLabel[key],
|
|
value: statsWithCurrentAndRecommended[key],
|
|
}))
|
|
.sortBy('value');
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
@action
|
|
isShown() {
|
|
next(() => {
|
|
this.shown = true;
|
|
});
|
|
}
|
|
|
|
@action
|
|
onResize() {
|
|
this.width = this.svgElement.clientWidth;
|
|
this.height = this.svgElement.clientHeight;
|
|
}
|
|
|
|
@action
|
|
storeSvgElement(element) {
|
|
this.svgElement = element;
|
|
}
|
|
|
|
@action
|
|
setLegendPosition(mouseMoveEvent) {
|
|
this.showLegend = true;
|
|
this.mouseX = mouseMoveEvent.layerX;
|
|
}
|
|
|
|
@action
|
|
setActiveLegendRow(row) {
|
|
this.activeLegendRow = row;
|
|
}
|
|
|
|
@action
|
|
unsetActiveLegendRow() {
|
|
this.activeLegendRow = undefined;
|
|
}
|
|
}
|