2019-10-17 05:38:09 -07:00
|
|
|
import $ from 'jquery';
|
|
|
|
import React, { PureComponent } from 'react';
|
|
|
|
import ReactResizeDetector from 'react-resize-detector';
|
|
|
|
import { Alert } from 'reactstrap';
|
|
|
|
|
2019-10-17 07:34:02 -07:00
|
|
|
import Legend from './Legend';
|
|
|
|
|
2019-10-17 05:38:09 -07:00
|
|
|
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');
|
|
|
|
|
2019-10-28 07:02:42 -07:00
|
|
|
let graphID = 0;
|
2019-10-17 05:38:09 -07:00
|
|
|
function getGraphID() {
|
|
|
|
// TODO: This is ugly.
|
|
|
|
return graphID++;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface GraphProps {
|
|
|
|
data: any; // TODO: Type this.
|
|
|
|
stacked: boolean;
|
|
|
|
queryParams: {
|
2019-10-28 07:02:42 -07:00
|
|
|
startTime: number;
|
|
|
|
endTime: number;
|
|
|
|
resolution: number;
|
2019-10-17 05:38:09 -07:00
|
|
|
} | null;
|
|
|
|
}
|
|
|
|
|
|
|
|
class Graph extends PureComponent<GraphProps> {
|
|
|
|
private id: number = getGraphID();
|
|
|
|
private chartRef = React.createRef<HTMLDivElement>();
|
|
|
|
|
|
|
|
escapeHTML(str: string) {
|
2019-10-28 07:02:42 -07:00
|
|
|
const entityMap: { [key: string]: string } = {
|
2019-10-17 05:38:09 -07:00
|
|
|
'&': '&',
|
|
|
|
'<': '<',
|
|
|
|
'>': '>',
|
|
|
|
'"': '"',
|
|
|
|
"'": ''',
|
2019-10-28 07:02:42 -07:00
|
|
|
'/': '/',
|
2019-10-17 05:38:09 -07:00
|
|
|
};
|
|
|
|
|
2019-10-28 07:02:42 -07:00
|
|
|
return String(str).replace(/[&<>"'/]/g, function(s) {
|
2019-10-17 05:38:09 -07:00
|
|
|
return entityMap[s];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-10-28 07:02:42 -07:00
|
|
|
renderLabels(labels: { [key: string]: string }) {
|
|
|
|
const labelStrings: string[] = [];
|
|
|
|
for (const label in labels) {
|
2019-10-17 05:38:09 -07:00
|
|
|
if (label !== '__name__') {
|
|
|
|
labelStrings.push('<strong>' + label + '</strong>: ' + this.escapeHTML(labels[label]));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
|
2019-10-28 07:02:42 -07:00
|
|
|
}
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
formatValue = (y: number | null): string => {
|
|
|
|
if (y === null) {
|
|
|
|
return 'null';
|
|
|
|
}
|
2019-10-28 07:02:42 -07:00
|
|
|
const abs_y = Math.abs(y);
|
2019-10-17 05:38:09 -07:00
|
|
|
if (abs_y >= 1e24) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e24).toFixed(2) + 'Y';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e21) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e21).toFixed(2) + 'Z';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e18) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e18).toFixed(2) + 'E';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e15) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e15).toFixed(2) + 'P';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e12) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e12).toFixed(2) + 'T';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e9) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e9).toFixed(2) + 'G';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e6) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e6).toFixed(2) + 'M';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1e3) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e3).toFixed(2) + 'k';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y >= 1) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return y.toFixed(2);
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y === 0) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return y.toFixed(2);
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-24) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-24).toFixed(2) + 'y';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-21) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-21).toFixed(2) + 'z';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-18) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-18).toFixed(2) + 'a';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-15) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-15).toFixed(2) + 'f';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-12) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-12).toFixed(2) + 'p';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-9) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-9).toFixed(2) + 'n';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1e-6) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (y / 1e-6).toFixed(2) + 'µ';
|
|
|
|
} else if (abs_y <= 1e-3) {
|
|
|
|
return (y / 1e-3).toFixed(2) + 'm';
|
2019-10-17 05:38:09 -07:00
|
|
|
} else if (abs_y <= 1) {
|
2019-10-28 07:02:42 -07:00
|
|
|
return y.toFixed(2);
|
2019-10-17 05:38:09 -07:00
|
|
|
}
|
|
|
|
throw Error("couldn't format a value, this is a bug");
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
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.
|
2019-10-28 07:02:42 -07:00
|
|
|
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>';
|
2019-10-17 05:38:09 -07:00
|
|
|
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,
|
2019-10-28 07:02:42 -07:00
|
|
|
},
|
2019-10-17 05:38:09 -07:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// This was adapted from Flot's color generation code.
|
|
|
|
getColors() {
|
2019-10-28 07:02:42 -07:00
|
|
|
const colors = [];
|
|
|
|
const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
|
2019-10-17 05:38:09 -07:00
|
|
|
const colorPoolSize = colorPool.length;
|
|
|
|
let variation = 0;
|
|
|
|
const neededColors = this.props.data.result.length;
|
|
|
|
|
|
|
|
for (let i = 0; i < neededColors; i++) {
|
2019-10-28 07:02:42 -07:00
|
|
|
const c = ($ as any).color.parse(colorPool[i % colorPoolSize] || '#666');
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
// 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.
|
2019-10-28 07:02:42 -07:00
|
|
|
const data = [];
|
2019-10-17 05:38:09 -07:00
|
|
|
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,
|
|
|
|
};
|
2019-10-28 07:02:42 -07:00
|
|
|
});
|
2019-10-17 05:38:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
parseValue(value: string) {
|
2019-10-28 07:02:42 -07:00
|
|
|
const val = parseFloat(value);
|
2019-10-17 05:38:09 -07:00
|
|
|
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;
|
2019-10-28 07:02:42 -07:00
|
|
|
}
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
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') {
|
2019-10-28 07:02:42 -07:00
|
|
|
return (
|
|
|
|
<Alert color="danger">
|
|
|
|
Query result is of wrong type '{this.props.data.resultType}', should be 'matrix' (range vector).
|
|
|
|
</Alert>
|
|
|
|
);
|
2019-10-17 05:38:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
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} />
|
2019-10-28 07:02:42 -07:00
|
|
|
<Legend series={this.getData()} />
|
2019-10-17 05:38:09 -07:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default Graph;
|