prometheus/web/ui/react-app/src/Graph.tsx
Dustin Hooten e0443e6fa3 Add unit tests for react-app (#6234)
* Add MetricFormat unit tests

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add App unit tests; Add debug script

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add Checkbox unit tests

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add SeriesName unit tests

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add QueryStatsView unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add Legend unit tests

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Remove unused utils/func

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add utils/urlParams unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add utils/timeFormat unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add ExpressionInput unit tests

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add GraphControls unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add TimeInput unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add DataTable unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add SanitizeHTML unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add Graph unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Extract utils/html

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add PanelList unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add Panel unit test

Signed-off-by: Dustin Hooten <dhooten@splunk.com>

* Add react-ui test coverage report to CI

Signed-off-by: Dustin Hooten <dhooten@splunk.com>
2019-10-28 18:09:48 +01:00

275 lines
7.8 KiB
TypeScript

import $ from 'jquery';
import React, { PureComponent } from 'react';
import ReactResizeDetector from 'react-resize-detector';
import { Alert } from 'reactstrap';
import Legend from './Legend';
import { escapeHTML } from './utils/html';
require('flot');
require('flot/source/jquery.flot.crosshair');
require('flot/source/jquery.flot.legend');
require('flot/source/jquery.flot.time');
require('flot/source/jquery.canvaswrapper');
require('jquery.flot.tooltip');
let graphID = 0;
function getGraphID() {
// TODO: This is ugly.
return graphID++;
}
interface GraphProps {
data: any; // TODO: Type this.
stacked: boolean;
queryParams: {
startTime: number;
endTime: number;
resolution: number;
} | null;
}
class Graph extends PureComponent<GraphProps> {
private id: number = getGraphID();
private chartRef = React.createRef<HTMLDivElement>();
renderLabels(labels: { [key: string]: string }) {
let labelStrings: string[] = [];
for (const label in labels) {
if (label !== '__name__') {
labelStrings.push('<strong>' + label + '</strong>: ' + escapeHTML(labels[label]));
}
}
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
}
formatValue = (y: number | null): string => {
if (y === null) {
return 'null';
}
const abs_y = Math.abs(y);
if (abs_y >= 1e24) {
return (y / 1e24).toFixed(2) + 'Y';
} else if (abs_y >= 1e21) {
return (y / 1e21).toFixed(2) + 'Z';
} else if (abs_y >= 1e18) {
return (y / 1e18).toFixed(2) + 'E';
} else if (abs_y >= 1e15) {
return (y / 1e15).toFixed(2) + 'P';
} else if (abs_y >= 1e12) {
return (y / 1e12).toFixed(2) + 'T';
} else if (abs_y >= 1e9) {
return (y / 1e9).toFixed(2) + 'G';
} else if (abs_y >= 1e6) {
return (y / 1e6).toFixed(2) + 'M';
} else if (abs_y >= 1e3) {
return (y / 1e3).toFixed(2) + 'k';
} else if (abs_y >= 1) {
return y.toFixed(2);
} else if (abs_y === 0) {
return y.toFixed(2);
} else if (abs_y <= 1e-24) {
return (y / 1e-24).toFixed(2) + 'y';
} else if (abs_y <= 1e-21) {
return (y / 1e-21).toFixed(2) + 'z';
} else if (abs_y <= 1e-18) {
return (y / 1e-18).toFixed(2) + 'a';
} else if (abs_y <= 1e-15) {
return (y / 1e-15).toFixed(2) + 'f';
} else if (abs_y <= 1e-12) {
return (y / 1e-12).toFixed(2) + 'p';
} else if (abs_y <= 1e-9) {
return (y / 1e-9).toFixed(2) + 'n';
} else if (abs_y <= 1e-6) {
return (y / 1e-6).toFixed(2) + 'µ';
} else if (abs_y <= 1e-3) {
return (y / 1e-3).toFixed(2) + 'm';
} else if (abs_y <= 1) {
return y.toFixed(2);
}
throw Error("couldn't format a value, this is a bug");
};
getOptions(): any {
return {
grid: {
hoverable: true,
clickable: true,
autoHighlight: true,
mouseActiveRadius: 100,
},
legend: {
show: false,
},
xaxis: {
mode: 'time',
showTicks: true,
showMinorTicks: true,
timeBase: 'milliseconds',
},
yaxis: {
tickFormatter: this.formatValue,
},
crosshair: {
mode: 'xy',
color: '#bbb',
},
tooltip: {
show: true,
cssClass: 'graph-tooltip',
content: (label: string, xval: number, yval: number, flotItem: any) => {
const series = flotItem.series; // TODO: type this.
const date = '<span class="date">' + new Date(xval).toUTCString() + '</span>';
const swatch = '<span class="detail-swatch" style="background-color: ' + series.color + '"></span>';
const content = swatch + (series.labels.__name__ || 'value') + ': <strong>' + yval + '</strong>';
return date + '<br>' + content + '<br>' + this.renderLabels(series.labels);
},
defaultTheme: false,
lines: true,
},
series: {
stack: this.props.stacked,
lines: {
lineWidth: this.props.stacked ? 1 : 2,
steps: false,
fill: this.props.stacked,
},
shadowSize: 0,
},
};
}
// This was adapted from Flot's color generation code.
getColors() {
const colors = [];
const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
const colorPoolSize = colorPool.length;
let variation = 0;
const neededColors = this.props.data.result.length;
for (let i = 0; i < neededColors; i++) {
const c = ($ as any).color.parse(colorPool[i % colorPoolSize] || '#666');
// Each time we exhaust the colors in the pool we adjust
// a scaling factor used to produce more variations on
// those colors. The factor alternates negative/positive
// to produce lighter/darker colors.
// Reset the variation after every few cycles, or else
// it will end up producing only white or black colors.
if (i % colorPoolSize === 0 && i) {
if (variation >= 0) {
if (variation < 0.5) {
variation = -variation - 0.2;
} else variation = 0;
} else variation = -variation;
}
colors[i] = c.scale('rgb', 1 + variation);
}
return colors;
}
getData() {
const colors = this.getColors();
return this.props.data.result.map((ts: any /* TODO: Type this*/, index: number) => {
// Insert nulls for all missing steps.
const data = [];
let pos = 0;
const params = this.props.queryParams!;
for (let t = params.startTime; t <= params.endTime; t += params.resolution) {
// Allow for floating point inaccuracy.
if (ts.values.length > pos && ts.values[pos][0] < t + params.resolution / 100) {
data.push([ts.values[pos][0] * 1000, this.parseValue(ts.values[pos][1])]);
pos++;
} else {
// TODO: Flot has problems displaying intermittent "null" values when stacked,
// resort to 0 now. In Grafana this works for some reason, figure out how they
// do it.
data.push([t * 1000, this.props.stacked ? 0 : null]);
}
}
return {
labels: ts.metric !== null ? ts.metric : {},
data: data,
color: colors[index],
index: index,
};
});
}
parseValue(value: string) {
const val = parseFloat(value);
if (isNaN(val)) {
// "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They
// can't be graphed, so show them as gaps (null).
// TODO: Flot has problems displaying intermittent "null" values when stacked,
// resort to 0 now. In Grafana this works for some reason, figure out how they
// do it.
return this.props.stacked ? 0 : null;
}
return val;
}
componentDidMount() {
this.plot();
}
componentDidUpdate() {
this.plot();
}
componentWillUnmount() {
this.destroyPlot();
}
plot() {
if (this.chartRef.current === null) {
return;
}
this.destroyPlot();
$.plot($(this.chartRef.current!), this.getData(), this.getOptions());
}
destroyPlot() {
const chart = $(this.chartRef.current!).data('plot');
if (chart !== undefined) {
chart.destroy();
}
}
render() {
if (this.props.data === null) {
return <Alert color="light">No data queried yet</Alert>;
}
if (this.props.data.resultType !== 'matrix') {
return (
<Alert color="danger">
Query result is of wrong type '{this.props.data.resultType}', should be 'matrix' (range vector).
</Alert>
);
}
if (this.props.data.result.length === 0) {
return <Alert color="secondary">Empty query result</Alert>;
}
return (
<div className="graph">
<ReactResizeDetector handleWidth onResize={() => this.plot()} />
<div className="graph-chart" ref={this.chartRef} />
<Legend series={this.getData()} />
</div>
);
}
}
export default Graph;