mirror of
https://github.com/prometheus/prometheus.git
synced 2025-03-05 20:59:13 -08:00
React UI: Graph refactoring (#6382)
* move graph related files into own folder Signed-off-by: blalov <boiskila@gmail.com> * move graph helper functions into own file Signed-off-by: blalov <boiskila@gmail.com> * fix typo in file name Signed-off-by: blalov <boiskila@gmail.com> * fix typo in file name and lint fixes Signed-off-by: blalov <boiskila@gmail.com>
This commit is contained in:
parent
408574a6e1
commit
28470c229c
|
@ -1,326 +0,0 @@
|
|||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
import { escapeHTML } from './utils/html';
|
||||
import SeriesName from './SeriesName';
|
||||
import { Metric, QueryParams } from './types/types';
|
||||
import { isPresent } from './utils/func';
|
||||
|
||||
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');
|
||||
|
||||
interface GraphProps {
|
||||
data: {
|
||||
resultType: string;
|
||||
result: Array<{ metric: Metric; values: [number, string][] }>;
|
||||
};
|
||||
stacked: boolean;
|
||||
queryParams: QueryParams | null;
|
||||
}
|
||||
|
||||
interface GraphSeries {
|
||||
labels: { [key: string]: string };
|
||||
color: string;
|
||||
data: (number | null)[][]; // [x,y][]
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
selectedSeriesIndex: number | null;
|
||||
chartData: GraphSeries[];
|
||||
}
|
||||
|
||||
class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
private chartRef = React.createRef<HTMLDivElement>();
|
||||
private $chart?: jquery.flot.plot;
|
||||
private rafID = 0;
|
||||
|
||||
state = {
|
||||
selectedSeriesIndex: null,
|
||||
chartData: this.getData(),
|
||||
};
|
||||
|
||||
formatValue = (y: number | null): string => {
|
||||
if (y === null) {
|
||||
return 'null';
|
||||
}
|
||||
const absY = Math.abs(y);
|
||||
|
||||
if (absY >= 1e24) {
|
||||
return (y / 1e24).toFixed(2) + 'Y';
|
||||
} else if (absY >= 1e21) {
|
||||
return (y / 1e21).toFixed(2) + 'Z';
|
||||
} else if (absY >= 1e18) {
|
||||
return (y / 1e18).toFixed(2) + 'E';
|
||||
} else if (absY >= 1e15) {
|
||||
return (y / 1e15).toFixed(2) + 'P';
|
||||
} else if (absY >= 1e12) {
|
||||
return (y / 1e12).toFixed(2) + 'T';
|
||||
} else if (absY >= 1e9) {
|
||||
return (y / 1e9).toFixed(2) + 'G';
|
||||
} else if (absY >= 1e6) {
|
||||
return (y / 1e6).toFixed(2) + 'M';
|
||||
} else if (absY >= 1e3) {
|
||||
return (y / 1e3).toFixed(2) + 'k';
|
||||
} else if (absY >= 1) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY === 0) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY < 1e-23) {
|
||||
return (y / 1e-24).toFixed(2) + 'y';
|
||||
} else if (absY < 1e-20) {
|
||||
return (y / 1e-21).toFixed(2) + 'z';
|
||||
} else if (absY < 1e-17) {
|
||||
return (y / 1e-18).toFixed(2) + 'a';
|
||||
} else if (absY < 1e-14) {
|
||||
return (y / 1e-15).toFixed(2) + 'f';
|
||||
} else if (absY < 1e-11) {
|
||||
return (y / 1e-12).toFixed(2) + 'p';
|
||||
} else if (absY < 1e-8) {
|
||||
return (y / 1e-9).toFixed(2) + 'n';
|
||||
} else if (absY < 1e-5) {
|
||||
return (y / 1e-6).toFixed(2) + 'µ';
|
||||
} else if (absY < 1e-2) {
|
||||
return (y / 1e-3).toFixed(2) + 'm';
|
||||
} else if (absY <= 1) {
|
||||
return y.toFixed(2);
|
||||
}
|
||||
throw Error("couldn't format a value, this is a bug");
|
||||
};
|
||||
|
||||
getOptions(): jquery.flot.plotOptions {
|
||||
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: (_, xval, yval, { series }): string => {
|
||||
const { labels, color } = series;
|
||||
return `
|
||||
<div class="date">${new Date(xval).toUTCString()}</div>
|
||||
<div>
|
||||
<span class="detail-swatch" style="background-color: ${color}" />
|
||||
<span>${labels.__name__ || 'value'}: <strong>${yval}</strong></span>
|
||||
<div>
|
||||
<div class="labels mt-1">
|
||||
${Object.keys(labels)
|
||||
.map(k =>
|
||||
k !== '__name__' ? `<div class="mb-1"><strong>${k}</strong>: ${escapeHTML(labels[k])}</div>` : ''
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
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 colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
|
||||
const colorPoolSize = colorPool.length;
|
||||
let variation = 0;
|
||||
return this.props.data.result.map((_, i) => {
|
||||
// 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) {
|
||||
variation = variation < 0.5 ? -variation - 0.2 : 0;
|
||||
} else {
|
||||
variation = -variation;
|
||||
}
|
||||
}
|
||||
return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation);
|
||||
});
|
||||
}
|
||||
|
||||
getData(): GraphSeries[] {
|
||||
const colors = this.getColors();
|
||||
const { stacked, queryParams } = this.props;
|
||||
const { startTime, endTime, resolution } = queryParams!;
|
||||
return this.props.data.result.map(({ values, metric }, index) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data = [];
|
||||
let pos = 0;
|
||||
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values[pos];
|
||||
if (values.length > pos && currentValue[0] < t + resolution / 100) {
|
||||
data.push([currentValue[0] * 1000, this.parseValue(currentValue[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, stacked ? 0 : null]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colors[index].toString(),
|
||||
data,
|
||||
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;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps) {
|
||||
if (prevProps.data !== this.props.data || prevProps.stacked !== this.props.stacked) {
|
||||
this.setState({ selectedSeriesIndex: null, chartData: this.getData() }, this.plot);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyPlot();
|
||||
}
|
||||
|
||||
plot = () => {
|
||||
if (!this.chartRef.current) {
|
||||
return;
|
||||
}
|
||||
this.destroyPlot();
|
||||
|
||||
this.$chart = $.plot($(this.chartRef.current), this.state.chartData, this.getOptions());
|
||||
};
|
||||
|
||||
destroyPlot = () => {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.$chart.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
plotSetAndDraw(data: GraphSeries[] = this.state.chartData) {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.$chart.setData(data);
|
||||
this.$chart.draw();
|
||||
}
|
||||
}
|
||||
|
||||
handleSeriesSelect = (index: number) => () => {
|
||||
const { selectedSeriesIndex, chartData } = this.state;
|
||||
this.plotSetAndDraw(
|
||||
selectedSeriesIndex === index ? chartData.map(this.toHoverColor(index)) : chartData.slice(index, index + 1)
|
||||
);
|
||||
this.setState({ selectedSeriesIndex: selectedSeriesIndex === index ? null : index });
|
||||
};
|
||||
|
||||
handleSeriesHover = (index: number) => () => {
|
||||
if (this.rafID) {
|
||||
cancelAnimationFrame(this.rafID);
|
||||
}
|
||||
this.rafID = requestAnimationFrame(() => {
|
||||
this.plotSetAndDraw(this.state.chartData.map(this.toHoverColor(index)));
|
||||
});
|
||||
};
|
||||
|
||||
handleLegendMouseOut = () => {
|
||||
cancelAnimationFrame(this.rafID);
|
||||
this.plotSetAndDraw();
|
||||
};
|
||||
|
||||
getHoverColor = (color: string, opacity: number) => {
|
||||
const { r, g, b } = $.color.parse(color);
|
||||
if (!this.props.stacked) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
/*
|
||||
Unfortunetly flot doesn't take into consideration
|
||||
the alpha value when adjusting the color on the stacked series.
|
||||
TODO: find better way to set the opacity.
|
||||
*/
|
||||
const base = (1 - opacity) * 255;
|
||||
return `rgb(${Math.round(base + opacity * r)},${Math.round(base + opacity * g)},${Math.round(base + opacity * b)})`;
|
||||
};
|
||||
|
||||
toHoverColor = (index: number) => (series: GraphSeries, i: number) => ({
|
||||
...series,
|
||||
color: this.getHoverColor(series.color, i !== index ? 0.3 : 1),
|
||||
});
|
||||
|
||||
render() {
|
||||
const { selectedSeriesIndex, chartData } = this.state;
|
||||
const canUseHover = chartData.length > 1 && selectedSeriesIndex === null;
|
||||
|
||||
return (
|
||||
<div className="graph">
|
||||
<ReactResizeDetector handleWidth onResize={this.plot} />
|
||||
<div className="graph-chart" ref={this.chartRef} />
|
||||
<div className="graph-legend" onMouseOut={canUseHover ? this.handleLegendMouseOut : undefined}>
|
||||
{chartData.map(({ index, color, labels }) => (
|
||||
<div
|
||||
style={{ opacity: selectedSeriesIndex === null || index === selectedSeriesIndex ? 1 : 0.5 }}
|
||||
onClick={chartData.length > 1 ? this.handleSeriesSelect(index) : undefined}
|
||||
onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined}
|
||||
key={index}
|
||||
className="legend-item"
|
||||
>
|
||||
<span className="legend-swatch" style={{ backgroundColor: color }}></span>
|
||||
<SeriesName labels={labels} format />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
|
@ -2,11 +2,11 @@ import * as React from 'react';
|
|||
import { mount, shallow } from 'enzyme';
|
||||
import Panel, { PanelOptions, PanelType } from './Panel';
|
||||
import ExpressionInput from './ExpressionInput';
|
||||
import GraphControls from './GraphControls';
|
||||
import GraphControls from './graph/GraphControls';
|
||||
import { NavLink, TabPane } from 'reactstrap';
|
||||
import TimeInput from './TimeInput';
|
||||
import DataTable from './DataTable';
|
||||
import { GraphTabContent } from './GraphTabContent';
|
||||
import { GraphTabContent } from './graph/GraphTabContent';
|
||||
|
||||
describe('Panel', () => {
|
||||
const props = {
|
||||
|
|
|
@ -5,8 +5,8 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
import ExpressionInput from './ExpressionInput';
|
||||
import GraphControls from './GraphControls';
|
||||
import { GraphTabContent } from './GraphTabContent';
|
||||
import GraphControls from './graph/GraphControls';
|
||||
import { GraphTabContent } from './graph/GraphTabContent';
|
||||
import DataTable from './DataTable';
|
||||
import TimeInput from './TimeInput';
|
||||
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||
|
|
|
@ -68,94 +68,10 @@ describe('Graph', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
describe('formatValue', () => {
|
||||
it('formats tick values correctly', () => {
|
||||
const graph = new Graph({ data: { result: [] }, queryParams: {} } as any);
|
||||
[
|
||||
{ input: null, output: 'null' },
|
||||
{ input: 0, output: '0.00' },
|
||||
{ input: 2e24, output: '2.00Y' },
|
||||
{ input: 2e23, output: '200.00Z' },
|
||||
{ input: 2e22, output: '20.00Z' },
|
||||
{ input: 2e21, output: '2.00Z' },
|
||||
{ input: 2e19, output: '20.00E' },
|
||||
{ input: 2e18, output: '2.00E' },
|
||||
{ input: 2e17, output: '200.00P' },
|
||||
{ input: 2e16, output: '20.00P' },
|
||||
{ input: 2e15, output: '2.00P' },
|
||||
{ input: 1e15, output: '1.00P' },
|
||||
{ input: 2e14, output: '200.00T' },
|
||||
{ input: 2e13, output: '20.00T' },
|
||||
{ input: 2e12, output: '2.00T' },
|
||||
{ input: 2e11, output: '200.00G' },
|
||||
{ input: 2e10, output: '20.00G' },
|
||||
{ input: 2e9, output: '2.00G' },
|
||||
{ input: 2e8, output: '200.00M' },
|
||||
{ input: 2e7, output: '20.00M' },
|
||||
{ input: 2e6, output: '2.00M' },
|
||||
{ input: 2e5, output: '200.00k' },
|
||||
{ input: 2e4, output: '20.00k' },
|
||||
{ input: 2e3, output: '2.00k' },
|
||||
{ input: 2e2, output: '200.00' },
|
||||
{ input: 2e1, output: '20.00' },
|
||||
{ input: 2, output: '2.00' },
|
||||
{ input: 2e-1, output: '0.20' },
|
||||
{ input: 2e-2, output: '0.02' },
|
||||
{ input: 2e-3, output: '2.00m' },
|
||||
{ input: 2e-4, output: '0.20m' },
|
||||
{ input: 2e-5, output: '0.02m' },
|
||||
{ input: 2e-6, output: '2.00µ' },
|
||||
{ input: 2e-7, output: '0.20µ' },
|
||||
{ input: 2e-8, output: '0.02µ' },
|
||||
{ input: 2e-9, output: '2.00n' },
|
||||
{ input: 2e-10, output: '0.20n' },
|
||||
{ input: 2e-11, output: '0.02n' },
|
||||
{ input: 2e-12, output: '2.00p' },
|
||||
{ input: 2e-13, output: '0.20p' },
|
||||
{ input: 2e-14, output: '0.02p' },
|
||||
{ input: 2e-15, output: '2.00f' },
|
||||
{ input: 2e-16, output: '0.20f' },
|
||||
{ input: 2e-17, output: '0.02f' },
|
||||
{ input: 2e-18, output: '2.00a' },
|
||||
{ input: 2e-19, output: '0.20a' },
|
||||
{ input: 2e-20, output: '0.02a' },
|
||||
{ input: 1e-21, output: '1.00z' },
|
||||
{ input: 2e-21, output: '2.00z' },
|
||||
{ input: 2e-22, output: '0.20z' },
|
||||
{ input: 2e-23, output: '0.02z' },
|
||||
{ input: 2e-24, output: '2.00y' },
|
||||
{ input: 2e-25, output: '0.20y' },
|
||||
{ input: 2e-26, output: '0.02y' },
|
||||
].map(t => {
|
||||
expect(graph.formatValue(t.input)).toBe(t.output);
|
||||
});
|
||||
});
|
||||
it('should throw error if no match', () => {
|
||||
const graph = new Graph({ data: { result: [] }, queryParams: {} } as any);
|
||||
try {
|
||||
graph.formatValue(undefined as any);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual("couldn't format a value, this is a bug");
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('getColors', () => {
|
||||
it('should generate proper colors', () => {
|
||||
const graph = new Graph({ data: { result: [{}, {}, {}, {}, {}, {}, {}, {}, {}] }, queryParams: {} } as any);
|
||||
expect(
|
||||
graph
|
||||
.getColors()
|
||||
.map(c => c.toString())
|
||||
.join(',')
|
||||
).toEqual(
|
||||
'rgb(237,194,64),rgb(175,216,248),rgb(203,75,75),rgb(77,167,77),rgb(148,64,237),rgb(189,155,51),rgb(140,172,198),rgb(162,60,60),rgb(61,133,61)'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('on component update', () => {
|
||||
let graph: any;
|
||||
beforeEach(() => {
|
||||
jest.spyOn($, 'plot').mockImplementation(() => {});
|
||||
jest.spyOn($, 'plot').mockImplementation(() => ({} as any));
|
||||
graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
|
@ -230,27 +146,16 @@ describe('Graph', () => {
|
|||
expect(spyPlotDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('parseValue', () => {
|
||||
it('should parse number properly', () => {
|
||||
expect(new Graph({ stacked: true, data: { result: [] }, queryParams: {} } as any).parseValue('12.3e')).toEqual(12.3);
|
||||
});
|
||||
it('should return 0 if value is NaN and stacked prop is true', () => {
|
||||
expect(new Graph({ stacked: true, data: { result: [] }, queryParams: {} } as any).parseValue('asd')).toEqual(0);
|
||||
});
|
||||
it('should return null if value is NaN and stacked prop is false', () => {
|
||||
expect(new Graph({ stacked: false, data: { result: [] }, queryParams: {} } as any).parseValue('asd')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plot', () => {
|
||||
let spyFlot: any;
|
||||
beforeEach(() => {
|
||||
spyFlot = jest.spyOn($, 'plot').mockImplementation(() => {});
|
||||
spyFlot = jest.spyOn($, 'plot').mockImplementation(() => ({} as any));
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should not call jquery.plot if charRef not exist', () => {
|
||||
it('should not call jquery.plot if chartRef not exist', () => {
|
||||
const graph = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
|
@ -267,7 +172,7 @@ describe('Graph', () => {
|
|||
(graph.instance() as any).plot();
|
||||
expect(spyFlot).not.toBeCalled();
|
||||
});
|
||||
it('should call jquery.plot if charRef exist', () => {
|
||||
it('should call jquery.plot if chartRef exist', () => {
|
||||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
|
@ -356,123 +261,4 @@ describe('Graph', () => {
|
|||
expect(spySetData).toHaveBeenCalledWith(graph.state().chartData);
|
||||
});
|
||||
});
|
||||
describe('Plot options', () => {
|
||||
it('should configer options properly if stacked prop is true', () => {
|
||||
const wrapper = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
expect((wrapper.instance() as any).getOptions()).toMatchObject({
|
||||
series: {
|
||||
stack: true,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should configer options properly if stacked prop is false', () => {
|
||||
const wrapper = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: false,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
expect((wrapper.instance() as any).getOptions()).toMatchObject({
|
||||
series: {
|
||||
stack: false,
|
||||
lines: { lineWidth: 2, steps: false, fill: false },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should return proper tooltip html from options', () => {
|
||||
const wrapper = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: false,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
(wrapper.instance() as any)
|
||||
.getOptions()
|
||||
.tooltip.content('', 1572128592, 1572128592, { series: { labels: { foo: 1, bar: 2 }, color: '' } })
|
||||
).toEqual(`
|
||||
<div class="date">Mon, 19 Jan 1970 04:42:08 GMT</div>
|
||||
<div>
|
||||
<span class="detail-swatch" style="background-color: " />
|
||||
<span>value: <strong>1572128592</strong></span>
|
||||
<div>
|
||||
<div class="labels mt-1">
|
||||
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
it('should render Plot with proper options', () => {
|
||||
const wrapper = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
expect((wrapper.instance() as any).getOptions()).toEqual({
|
||||
grid: {
|
||||
hoverable: true,
|
||||
clickable: true,
|
||||
autoHighlight: true,
|
||||
mouseActiveRadius: 100,
|
||||
},
|
||||
legend: { show: false },
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
showTicks: true,
|
||||
showMinorTicks: true,
|
||||
timeBase: 'milliseconds',
|
||||
},
|
||||
yaxis: { tickFormatter: expect.anything() },
|
||||
crosshair: { mode: 'xy', color: '#bbb' },
|
||||
tooltip: {
|
||||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: expect.anything(),
|
||||
defaultTheme: false,
|
||||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: true,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
132
web/ui/react-app/src/graph/Graph.tsx
Normal file
132
web/ui/react-app/src/graph/Graph.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
import SeriesName from '../SeriesName';
|
||||
import { Metric, QueryParams } from '../types/types';
|
||||
import { isPresent } from '../utils/func';
|
||||
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
||||
|
||||
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');
|
||||
|
||||
export interface GraphProps {
|
||||
data: {
|
||||
resultType: string;
|
||||
result: Array<{ metric: Metric; values: [number, string][] }>;
|
||||
};
|
||||
stacked: boolean;
|
||||
queryParams: QueryParams | null;
|
||||
}
|
||||
|
||||
export interface GraphSeries {
|
||||
labels: { [key: string]: string };
|
||||
color: string;
|
||||
data: (number | null)[][]; // [x,y][]
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
selectedSeriesIndex: number | null;
|
||||
chartData: GraphSeries[];
|
||||
}
|
||||
|
||||
class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
private chartRef = React.createRef<HTMLDivElement>();
|
||||
private $chart?: jquery.flot.plot;
|
||||
private rafID = 0;
|
||||
|
||||
state = {
|
||||
selectedSeriesIndex: null,
|
||||
chartData: normalizeData(this.props),
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps) {
|
||||
const { data, stacked } = this.props;
|
||||
if (prevProps.data !== data || prevProps.stacked !== stacked) {
|
||||
this.setState({ selectedSeriesIndex: null, chartData: normalizeData(this.props) }, this.plot);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyPlot();
|
||||
}
|
||||
|
||||
plot = () => {
|
||||
if (!this.chartRef.current) {
|
||||
return;
|
||||
}
|
||||
this.destroyPlot();
|
||||
|
||||
this.$chart = $.plot($(this.chartRef.current), this.state.chartData, getOptions(this.props.stacked));
|
||||
};
|
||||
|
||||
destroyPlot = () => {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.$chart.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
plotSetAndDraw(data: GraphSeries[] = this.state.chartData) {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.$chart.setData(data);
|
||||
this.$chart.draw();
|
||||
}
|
||||
}
|
||||
|
||||
handleSeriesSelect = (index: number) => () => {
|
||||
const { selectedSeriesIndex, chartData } = this.state;
|
||||
this.plotSetAndDraw(
|
||||
selectedSeriesIndex === index
|
||||
? chartData.map(toHoverColor(index, this.props.stacked))
|
||||
: chartData.slice(index, index + 1)
|
||||
);
|
||||
this.setState({ selectedSeriesIndex: selectedSeriesIndex === index ? null : index });
|
||||
};
|
||||
|
||||
handleSeriesHover = (index: number) => () => {
|
||||
if (this.rafID) {
|
||||
cancelAnimationFrame(this.rafID);
|
||||
}
|
||||
this.rafID = requestAnimationFrame(() => {
|
||||
this.plotSetAndDraw(this.state.chartData.map(toHoverColor(index, this.props.stacked)));
|
||||
});
|
||||
};
|
||||
|
||||
handleLegendMouseOut = () => {
|
||||
cancelAnimationFrame(this.rafID);
|
||||
this.plotSetAndDraw();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { selectedSeriesIndex, chartData } = this.state;
|
||||
const canUseHover = chartData.length > 1 && selectedSeriesIndex === null;
|
||||
|
||||
return (
|
||||
<div className="graph">
|
||||
<ReactResizeDetector handleWidth onResize={this.plot} />
|
||||
<div className="graph-chart" ref={this.chartRef} />
|
||||
<div className="graph-legend" onMouseOut={canUseHover ? this.handleLegendMouseOut : undefined}>
|
||||
{chartData.map(({ index, color, labels }) => (
|
||||
<div
|
||||
style={{ opacity: selectedSeriesIndex === null || index === selectedSeriesIndex ? 1 : 0.5 }}
|
||||
onClick={chartData.length > 1 ? this.handleSeriesSelect(index) : undefined}
|
||||
onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined}
|
||||
key={index}
|
||||
className="legend-item"
|
||||
>
|
||||
<span className="legend-swatch" style={{ backgroundColor: color }}></span>
|
||||
<SeriesName labels={labels} format />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
|
@ -4,7 +4,7 @@ import GraphControls from './GraphControls';
|
|||
import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||
import TimeInput from './TimeInput';
|
||||
import TimeInput from '../TimeInput';
|
||||
|
||||
const defaultGraphControlProps = {
|
||||
range: 60 * 60 * 24,
|
|
@ -4,8 +4,8 @@ import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'r
|
|||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import TimeInput from './TimeInput';
|
||||
import { parseRange, formatRange } from './utils/timeFormat';
|
||||
import TimeInput from '../TimeInput';
|
||||
import { parseRange, formatRange } from '../utils/timeFormat';
|
||||
|
||||
interface GraphControlsProps {
|
||||
range: number;
|
167
web/ui/react-app/src/graph/GraphHelpers.test.ts
Normal file
167
web/ui/react-app/src/graph/GraphHelpers.test.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { formatValue, getColors, parseValue, getOptions } from './GraphHelpers';
|
||||
require('flot'); // need for $.colors
|
||||
|
||||
describe('GraphHelpers', () => {
|
||||
describe('formatValue', () => {
|
||||
it('formats tick values correctly', () => {
|
||||
[
|
||||
{ input: null, output: 'null' },
|
||||
{ input: 0, output: '0.00' },
|
||||
{ input: 2e24, output: '2.00Y' },
|
||||
{ input: 2e23, output: '200.00Z' },
|
||||
{ input: 2e22, output: '20.00Z' },
|
||||
{ input: 2e21, output: '2.00Z' },
|
||||
{ input: 2e19, output: '20.00E' },
|
||||
{ input: 2e18, output: '2.00E' },
|
||||
{ input: 2e17, output: '200.00P' },
|
||||
{ input: 2e16, output: '20.00P' },
|
||||
{ input: 2e15, output: '2.00P' },
|
||||
{ input: 1e15, output: '1.00P' },
|
||||
{ input: 2e14, output: '200.00T' },
|
||||
{ input: 2e13, output: '20.00T' },
|
||||
{ input: 2e12, output: '2.00T' },
|
||||
{ input: 2e11, output: '200.00G' },
|
||||
{ input: 2e10, output: '20.00G' },
|
||||
{ input: 2e9, output: '2.00G' },
|
||||
{ input: 2e8, output: '200.00M' },
|
||||
{ input: 2e7, output: '20.00M' },
|
||||
{ input: 2e6, output: '2.00M' },
|
||||
{ input: 2e5, output: '200.00k' },
|
||||
{ input: 2e4, output: '20.00k' },
|
||||
{ input: 2e3, output: '2.00k' },
|
||||
{ input: 2e2, output: '200.00' },
|
||||
{ input: 2e1, output: '20.00' },
|
||||
{ input: 2, output: '2.00' },
|
||||
{ input: 2e-1, output: '0.20' },
|
||||
{ input: 2e-2, output: '0.02' },
|
||||
{ input: 2e-3, output: '2.00m' },
|
||||
{ input: 2e-4, output: '0.20m' },
|
||||
{ input: 2e-5, output: '0.02m' },
|
||||
{ input: 2e-6, output: '2.00µ' },
|
||||
{ input: 2e-7, output: '0.20µ' },
|
||||
{ input: 2e-8, output: '0.02µ' },
|
||||
{ input: 2e-9, output: '2.00n' },
|
||||
{ input: 2e-10, output: '0.20n' },
|
||||
{ input: 2e-11, output: '0.02n' },
|
||||
{ input: 2e-12, output: '2.00p' },
|
||||
{ input: 2e-13, output: '0.20p' },
|
||||
{ input: 2e-14, output: '0.02p' },
|
||||
{ input: 2e-15, output: '2.00f' },
|
||||
{ input: 2e-16, output: '0.20f' },
|
||||
{ input: 2e-17, output: '0.02f' },
|
||||
{ input: 2e-18, output: '2.00a' },
|
||||
{ input: 2e-19, output: '0.20a' },
|
||||
{ input: 2e-20, output: '0.02a' },
|
||||
{ input: 1e-21, output: '1.00z' },
|
||||
{ input: 2e-21, output: '2.00z' },
|
||||
{ input: 2e-22, output: '0.20z' },
|
||||
{ input: 2e-23, output: '0.02z' },
|
||||
{ input: 2e-24, output: '2.00y' },
|
||||
{ input: 2e-25, output: '0.20y' },
|
||||
{ input: 2e-26, output: '0.02y' },
|
||||
].map(t => {
|
||||
expect(formatValue(t.input)).toBe(t.output);
|
||||
});
|
||||
});
|
||||
it('should throw error if no match', () => {
|
||||
try {
|
||||
formatValue(undefined as any);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual("couldn't format a value, this is a bug");
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('getColors', () => {
|
||||
it('should generate proper colors', () => {
|
||||
const data: any = {
|
||||
resultType: 'matrix',
|
||||
result: [{}, {}, {}, {}, {}, {}, {}],
|
||||
};
|
||||
expect(
|
||||
getColors(data)
|
||||
.map(c => c.toString())
|
||||
.join(',')
|
||||
).toEqual(
|
||||
'rgb(237,194,64),rgb(175,216,248),rgb(203,75,75),rgb(77,167,77),rgb(148,64,237),rgb(189,155,51),rgb(140,172,198)'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('parseValue', () => {
|
||||
it('should parse number properly', () => {
|
||||
expect(parseValue('12.3e', true)).toEqual(12.3);
|
||||
});
|
||||
it('should return 0 if value is NaN and stacked prop is true', () => {
|
||||
expect(parseValue('asd', true)).toEqual(0);
|
||||
});
|
||||
it('should return null if value is NaN and stacked prop is false', () => {
|
||||
expect(parseValue('asd', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('Plot options', () => {
|
||||
it('should configer options properly if stacked prop is true', () => {
|
||||
expect(getOptions(true)).toMatchObject({
|
||||
series: {
|
||||
stack: true,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should configer options properly if stacked prop is false', () => {
|
||||
expect(getOptions(false)).toMatchObject({
|
||||
series: {
|
||||
stack: false,
|
||||
lines: { lineWidth: 2, steps: false, fill: false },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should return proper tooltip html from options', () => {
|
||||
expect(
|
||||
getOptions(true).tooltip.content('', 1572128592, 1572128592, {
|
||||
series: { labels: { foo: '1', bar: '2' }, color: '' },
|
||||
} as any)
|
||||
).toEqual(`
|
||||
<div class="date">Mon, 19 Jan 1970 04:42:08 GMT</div>
|
||||
<div>
|
||||
<span class="detail-swatch" style="background-color: " />
|
||||
<span>value: <strong>1572128592</strong></span>
|
||||
<div>
|
||||
<div class="labels mt-1">
|
||||
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
it('should render Plot with proper options', () => {
|
||||
expect(getOptions(true)).toEqual({
|
||||
grid: {
|
||||
hoverable: true,
|
||||
clickable: true,
|
||||
autoHighlight: true,
|
||||
mouseActiveRadius: 100,
|
||||
},
|
||||
legend: { show: false },
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
showTicks: true,
|
||||
showMinorTicks: true,
|
||||
timeBase: 'milliseconds',
|
||||
},
|
||||
yaxis: { tickFormatter: expect.anything() },
|
||||
crosshair: { mode: 'xy', color: '#bbb' },
|
||||
tooltip: {
|
||||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: expect.anything(),
|
||||
defaultTheme: false,
|
||||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: true,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
201
web/ui/react-app/src/graph/GraphHelpers.ts
Normal file
201
web/ui/react-app/src/graph/GraphHelpers.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
import $ from 'jquery';
|
||||
|
||||
import { escapeHTML } from '../utils/html';
|
||||
import { Metric } from '../types/types';
|
||||
import { GraphProps, GraphSeries } from './Graph';
|
||||
|
||||
export const formatValue = (y: number | null): string => {
|
||||
if (y === null) {
|
||||
return 'null';
|
||||
}
|
||||
const absY = Math.abs(y);
|
||||
|
||||
if (absY >= 1e24) {
|
||||
return (y / 1e24).toFixed(2) + 'Y';
|
||||
} else if (absY >= 1e21) {
|
||||
return (y / 1e21).toFixed(2) + 'Z';
|
||||
} else if (absY >= 1e18) {
|
||||
return (y / 1e18).toFixed(2) + 'E';
|
||||
} else if (absY >= 1e15) {
|
||||
return (y / 1e15).toFixed(2) + 'P';
|
||||
} else if (absY >= 1e12) {
|
||||
return (y / 1e12).toFixed(2) + 'T';
|
||||
} else if (absY >= 1e9) {
|
||||
return (y / 1e9).toFixed(2) + 'G';
|
||||
} else if (absY >= 1e6) {
|
||||
return (y / 1e6).toFixed(2) + 'M';
|
||||
} else if (absY >= 1e3) {
|
||||
return (y / 1e3).toFixed(2) + 'k';
|
||||
} else if (absY >= 1) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY === 0) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY < 1e-23) {
|
||||
return (y / 1e-24).toFixed(2) + 'y';
|
||||
} else if (absY < 1e-20) {
|
||||
return (y / 1e-21).toFixed(2) + 'z';
|
||||
} else if (absY < 1e-17) {
|
||||
return (y / 1e-18).toFixed(2) + 'a';
|
||||
} else if (absY < 1e-14) {
|
||||
return (y / 1e-15).toFixed(2) + 'f';
|
||||
} else if (absY < 1e-11) {
|
||||
return (y / 1e-12).toFixed(2) + 'p';
|
||||
} else if (absY < 1e-8) {
|
||||
return (y / 1e-9).toFixed(2) + 'n';
|
||||
} else if (absY < 1e-5) {
|
||||
return (y / 1e-6).toFixed(2) + 'µ';
|
||||
} else if (absY < 1e-2) {
|
||||
return (y / 1e-3).toFixed(2) + 'm';
|
||||
} else if (absY <= 1) {
|
||||
return y.toFixed(2);
|
||||
}
|
||||
throw Error("couldn't format a value, this is a bug");
|
||||
};
|
||||
|
||||
export const getHoverColor = (color: string, opacity: number, stacked: boolean) => {
|
||||
const { r, g, b } = $.color.parse(color);
|
||||
if (!stacked) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
/*
|
||||
Unfortunetly flot doesn't take into consideration
|
||||
the alpha value when adjusting the color on the stacked series.
|
||||
TODO: find better way to set the opacity.
|
||||
*/
|
||||
const base = (1 - opacity) * 255;
|
||||
return `rgb(${Math.round(base + opacity * r)},${Math.round(base + opacity * g)},${Math.round(base + opacity * b)})`;
|
||||
};
|
||||
|
||||
export const toHoverColor = (index: number, stacked: boolean) => (series: GraphSeries, i: number) => ({
|
||||
...series,
|
||||
color: getHoverColor(series.color, i !== index ? 0.3 : 1, stacked),
|
||||
});
|
||||
|
||||
export const getOptions = (stacked: boolean): jquery.flot.plotOptions => {
|
||||
return {
|
||||
grid: {
|
||||
hoverable: true,
|
||||
clickable: true,
|
||||
autoHighlight: true,
|
||||
mouseActiveRadius: 100,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
showTicks: true,
|
||||
showMinorTicks: true,
|
||||
timeBase: 'milliseconds',
|
||||
},
|
||||
yaxis: {
|
||||
tickFormatter: formatValue,
|
||||
},
|
||||
crosshair: {
|
||||
mode: 'xy',
|
||||
color: '#bbb',
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: (_, xval, yval, { series }): string => {
|
||||
const { labels, color } = series;
|
||||
return `
|
||||
<div class="date">${new Date(xval).toUTCString()}</div>
|
||||
<div>
|
||||
<span class="detail-swatch" style="background-color: ${color}" />
|
||||
<span>${labels.__name__ || 'value'}: <strong>${yval}</strong></span>
|
||||
<div>
|
||||
<div class="labels mt-1">
|
||||
${Object.keys(labels)
|
||||
.map(k =>
|
||||
k !== '__name__' ? `<div class="mb-1"><strong>${k}</strong>: ${escapeHTML(labels[k])}</div>` : ''
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
defaultTheme: false,
|
||||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: stacked,
|
||||
lines: {
|
||||
lineWidth: stacked ? 1 : 2,
|
||||
steps: false,
|
||||
fill: stacked,
|
||||
},
|
||||
shadowSize: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// This was adapted from Flot's color generation code.
|
||||
export const getColors = (data: { resultType: string; result: Array<{ metric: Metric; values: [number, string][] }> }) => {
|
||||
const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
|
||||
const colorPoolSize = colorPool.length;
|
||||
let variation = 0;
|
||||
return data.result.map((_, i) => {
|
||||
// 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) {
|
||||
variation = variation < 0.5 ? -variation - 0.2 : 0;
|
||||
} else {
|
||||
variation = -variation;
|
||||
}
|
||||
}
|
||||
return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation);
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeData = ({ stacked, queryParams, data }: GraphProps): GraphSeries[] => {
|
||||
const colors = getColors(data);
|
||||
const { startTime, endTime, resolution } = queryParams!;
|
||||
return data.result.map(({ values, metric }, index) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data = [];
|
||||
let pos = 0;
|
||||
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values[pos];
|
||||
if (values.length > pos && currentValue[0] < t + resolution / 100) {
|
||||
data.push([currentValue[0] * 1000, parseValue(currentValue[1], stacked)]);
|
||||
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, stacked ? 0 : null]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colors[index].toString(),
|
||||
data,
|
||||
index,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const parseValue = (value: string, stacked: boolean) => {
|
||||
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 stacked ? 0 : null;
|
||||
}
|
||||
return val;
|
||||
};
|
45
web/ui/react-app/src/graph/GraphTabContent.test.tsx
Normal file
45
web/ui/react-app/src/graph/GraphTabContent.test.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Alert } from 'reactstrap';
|
||||
import { GraphTabContent } from './GraphTabContent';
|
||||
|
||||
describe('GraphTabContent', () => {
|
||||
it('renders an alert if data result type is different than "matrix"', () => {
|
||||
const props: any = {
|
||||
data: { resultType: 'invalid', result: [{}] },
|
||||
stacked: false,
|
||||
queryParams: {
|
||||
startTime: 1572100210000,
|
||||
endTime: 1572100217898,
|
||||
resolution: 10,
|
||||
},
|
||||
color: 'danger',
|
||||
children: `Query result is of wrong type '`,
|
||||
};
|
||||
const graph = shallow(<GraphTabContent {...props} />);
|
||||
const alert = graph.find(Alert);
|
||||
expect(alert.prop('color')).toEqual(props.color);
|
||||
expect(alert.childAt(0).text()).toEqual(props.children);
|
||||
});
|
||||
|
||||
it('renders an alert if data result empty', () => {
|
||||
const props: any = {
|
||||
data: {
|
||||
resultType: 'matrix',
|
||||
result: [],
|
||||
},
|
||||
color: 'secondary',
|
||||
children: 'Empty query result',
|
||||
stacked: false,
|
||||
queryParams: {
|
||||
startTime: 1572100210000,
|
||||
endTime: 1572100217898,
|
||||
resolution: 10,
|
||||
},
|
||||
};
|
||||
const graph = shallow(<GraphTabContent {...props} />);
|
||||
const alert = graph.find(Alert);
|
||||
expect(alert.prop('color')).toEqual(props.color);
|
||||
expect(alert.childAt(0).text()).toEqual(props.children);
|
||||
});
|
||||
});
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Alert } from 'reactstrap';
|
||||
import Graph from './Graph';
|
||||
import { QueryParams } from './types/types';
|
||||
import { isPresent } from './utils/func';
|
||||
import { QueryParams } from '../types/types';
|
||||
import { isPresent } from '../utils/func';
|
||||
|
||||
export const GraphTabContent = ({
|
||||
data,
|
Loading…
Reference in a new issue