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:
Boyko 2019-11-27 17:51:40 +02:00 committed by Julius Volz
parent 408574a6e1
commit 28470c229c
11 changed files with 558 additions and 553 deletions

View file

@ -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;

View file

@ -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 = {

View file

@ -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';

View file

@ -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,
},
});
});
});
});

View 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;

View file

@ -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,

View file

@ -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;

View 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,
},
});
});
});
});

View 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;
};

View 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);
});
});

View file

@ -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,