mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -08:00
React UI: Improve graph legend hover performance (#6367)
* improve hover performance Signed-off-by: blalov <boyko.lalov@tick42.com> * increase graph tests coverage Signed-off-by: blalov <boiskila@gmail.com> * wrap plotSetAndDraw into requestAnimationFrame to achieve smooth hover effect Signed-off-by: Boyko Lalov <boiskila@gmail.com> * unit tests Signed-off-by: Boyko Lalov <boiskila@gmail.com> * add destroy plot method to types Signed-off-by: blalov <boiskila@gmail.com> * make chart undefined by default Signed-off-by: Boyko Lalov <boiskila@gmail.com> * make destroy plot test more meaningful Signed-off-by: Boyko Lalov <boiskila@gmail.com> * remove chart.destroy extra check Signed-off-by: Boyko Lalov <boiskila@gmail.com>
This commit is contained in:
parent
bbd92b85da
commit
fa1489e35c
|
@ -1,49 +1,10 @@
|
|||
import * as React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import $ from 'jquery';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import Graph from './Graph';
|
||||
import { Alert } from 'reactstrap';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
describe('Graph', () => {
|
||||
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(<Graph {...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(<Graph {...props} />);
|
||||
const alert = graph.find(Alert);
|
||||
expect(alert.prop('color')).toEqual(props.color);
|
||||
expect(alert.childAt(0).text()).toEqual(props.children);
|
||||
});
|
||||
|
||||
describe('data is returned', () => {
|
||||
const props: any = {
|
||||
queryParams: {
|
||||
|
@ -100,9 +61,19 @@ describe('Graph', () => {
|
|||
expect(div).toHaveLength(1);
|
||||
expect(innerdiv).toHaveLength(1);
|
||||
});
|
||||
describe('Legend', () => {
|
||||
it('renders a legend', () => {
|
||||
const graph = shallow(<Graph {...props} />);
|
||||
expect(graph.find('.graph-legend .legend-item')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
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' },
|
||||
|
@ -159,10 +130,348 @@ describe('Graph', () => {
|
|||
expect(graph.formatValue(t.input)).toBe(t.output);
|
||||
});
|
||||
});
|
||||
describe('Legend', () => {
|
||||
it('renders a legend', () => {
|
||||
const graph = shallow(<Graph {...props} />);
|
||||
expect(graph.find('.graph-legend .legend-item')).toHaveLength(1);
|
||||
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(() => {});
|
||||
graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should trigger state update when new data is recieved', () => {
|
||||
const spyState = jest.spyOn(Graph.prototype, 'setState');
|
||||
graph.setProps({ data: { result: [{ values: [{}], metric: {} }] } });
|
||||
expect(spyState).toHaveBeenCalledWith(
|
||||
{
|
||||
chartData: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, 0]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
selectedSeriesIndex: null,
|
||||
},
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('should trigger state update when stacked prop is changed', () => {
|
||||
const spyState = jest.spyOn(Graph.prototype, 'setState');
|
||||
graph.setProps({ stacked: true });
|
||||
expect(spyState).toHaveBeenCalledWith(
|
||||
{
|
||||
chartData: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, 0]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
selectedSeriesIndex: null,
|
||||
},
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('on unmount', () => {
|
||||
it('should call destroy plot', () => {
|
||||
const wrapper = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
const spyPlotDestroy = jest.spyOn(Graph.prototype, 'componentWillUnmount');
|
||||
wrapper.unmount();
|
||||
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(() => {});
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should not call jquery.plot if charRef not exist', () => {
|
||||
const graph = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
(graph.instance() as any).plot();
|
||||
expect(spyFlot).not.toBeCalled();
|
||||
});
|
||||
it('should call jquery.plot if charRef exist', () => {
|
||||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
(graph.instance() as any).plot();
|
||||
expect(spyFlot).toBeCalled();
|
||||
});
|
||||
it('should destroy plot', () => {
|
||||
const spyPlotDestroy = jest.fn();
|
||||
jest.spyOn($, 'plot').mockReturnValue({ destroy: spyPlotDestroy } as any);
|
||||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
(graph.instance() as any).plot();
|
||||
(graph.instance() as any).destroyPlot();
|
||||
expect(spyPlotDestroy).toHaveBeenCalledTimes(1);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
describe('plotSetAndDraw', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should call spyPlotSetAndDraw on legend hover', () => {
|
||||
jest.spyOn($, 'plot').mockReturnValue({ setData: jest.fn(), draw: jest.fn() } as any);
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => cb());
|
||||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }, { values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
(graph.instance() as any).plot(); // create chart
|
||||
const spyPlotSetAndDraw = jest.spyOn(Graph.prototype, 'plotSetAndDraw');
|
||||
graph
|
||||
.find('.legend-item')
|
||||
.at(0)
|
||||
.simulate('mouseover');
|
||||
expect(spyPlotSetAndDraw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should call spyPlotSetAndDraw with chartDate from state as default value', () => {
|
||||
const spySetData = jest.fn();
|
||||
jest.spyOn($, 'plot').mockReturnValue({ setData: spySetData, draw: jest.fn() } as any);
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => cb());
|
||||
const graph: any = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
resolution: 28,
|
||||
},
|
||||
data: { result: [{ values: [], metric: {} }, { values: [], metric: {} }] },
|
||||
} as any)}
|
||||
/>
|
||||
);
|
||||
(graph.instance() as any).plot(); // create chart
|
||||
graph.find('.graph-legend').simulate('mouseout');
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
import { Alert } from 'reactstrap';
|
||||
|
||||
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');
|
||||
|
@ -22,25 +23,26 @@ interface GraphProps {
|
|||
queryParams: QueryParams | null;
|
||||
}
|
||||
|
||||
export interface GraphSeries {
|
||||
interface GraphSeries {
|
||||
labels: { [key: string]: string };
|
||||
color: string;
|
||||
normalizedColor: string;
|
||||
data: (number | null)[][]; // [x,y][]
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
selectedSeriesIndex: number | null;
|
||||
hoveredSeriesIndex: 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,
|
||||
hoveredSeriesIndex: null,
|
||||
chartData: this.getData(),
|
||||
};
|
||||
|
||||
formatValue = (y: number | null): string => {
|
||||
|
@ -48,6 +50,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
return 'null';
|
||||
}
|
||||
const absY = Math.abs(y);
|
||||
|
||||
if (absY >= 1e24) {
|
||||
return (y / 1e24).toFixed(2) + 'Y';
|
||||
} else if (absY >= 1e21) {
|
||||
|
@ -176,18 +179,17 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
|
||||
getData(): GraphSeries[] {
|
||||
const colors = this.getColors();
|
||||
const { hoveredSeriesIndex } = this.state;
|
||||
const { stacked, queryParams } = this.props;
|
||||
const { startTime, endTime, resolution } = queryParams!;
|
||||
return this.props.data.result.map((ts, index) => {
|
||||
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 = ts.values[pos];
|
||||
if (ts.values.length > pos && currentValue[0] < t + resolution / 100) {
|
||||
const currentValue = values[pos];
|
||||
if (values.length > pos && currentValue[0] < t + resolution / 100) {
|
||||
data.push([currentValue[0] * 1000, this.parseValue(currentValue[1])]);
|
||||
pos++;
|
||||
} else {
|
||||
|
@ -197,12 +199,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
data.push([t * 1000, stacked ? 0 : null]);
|
||||
}
|
||||
}
|
||||
const { r, g, b } = colors[index];
|
||||
|
||||
return {
|
||||
labels: ts.metric !== null ? ts.metric : {},
|
||||
color: `rgba(${r}, ${g}, ${b}, ${hoveredSeriesIndex === null || hoveredSeriesIndex === index ? 1 : 0.3})`,
|
||||
normalizedColor: `rgb(${r}, ${g}, ${b}`,
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colors[index].toString(),
|
||||
data,
|
||||
index,
|
||||
};
|
||||
|
@ -224,10 +224,9 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps) {
|
||||
if (prevProps.data !== this.props.data) {
|
||||
this.setState({ selectedSeriesIndex: null });
|
||||
if (prevProps.data !== this.props.data || prevProps.stacked !== this.props.stacked) {
|
||||
this.setState({ selectedSeriesIndex: null, chartData: this.getData() }, this.plot);
|
||||
}
|
||||
this.plot();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -238,64 +237,83 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
if (!this.chartRef.current) {
|
||||
return;
|
||||
}
|
||||
const selectedData = this.getData()[this.state.selectedSeriesIndex!];
|
||||
this.destroyPlot();
|
||||
$.plot($(this.chartRef.current), selectedData ? [selectedData] : this.getData(), this.getOptions());
|
||||
|
||||
this.$chart = $.plot($(this.chartRef.current), this.state.chartData, this.getOptions());
|
||||
};
|
||||
|
||||
destroyPlot() {
|
||||
const chart = $(this.chartRef.current!).data('plot');
|
||||
if (chart !== undefined) {
|
||||
chart.destroy();
|
||||
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 } = this.state;
|
||||
this.setState({ selectedSeriesIndex: selectedSeriesIndex !== index ? index : null });
|
||||
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) => () => {
|
||||
this.setState({ hoveredSeriesIndex: index });
|
||||
if (this.rafID) {
|
||||
cancelAnimationFrame(this.rafID);
|
||||
}
|
||||
this.rafID = requestAnimationFrame(() => {
|
||||
this.plotSetAndDraw(this.state.chartData.map(this.toHoverColor(index)));
|
||||
});
|
||||
};
|
||||
|
||||
handleLegendMouseOut = () => this.setState({ hoveredSeriesIndex: null });
|
||||
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() {
|
||||
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>;
|
||||
}
|
||||
|
||||
const { selectedSeriesIndex } = this.state;
|
||||
const series = this.getData();
|
||||
const canUseHover = series.length > 1 && selectedSeriesIndex === null;
|
||||
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}>
|
||||
{series.map(({ index, normalizedColor, labels }) => (
|
||||
{chartData.map(({ index, color, labels }) => (
|
||||
<div
|
||||
style={{ opacity: selectedSeriesIndex !== null && index !== selectedSeriesIndex ? 0.7 : 1 }}
|
||||
onClick={series.length > 1 ? this.handleSeriesSelect(index) : undefined}
|
||||
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: normalizedColor }}></span>
|
||||
<span className="legend-swatch" style={{ backgroundColor: color }}></span>
|
||||
<SeriesName labels={labels} format />
|
||||
</div>
|
||||
))}
|
||||
|
|
28
web/ui/react-app/src/GraphTabContent.tsx
Normal file
28
web/ui/react-app/src/GraphTabContent.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Alert } from 'reactstrap';
|
||||
import Graph from './Graph';
|
||||
import { QueryParams } from './types/types';
|
||||
import { isPresent } from './utils/func';
|
||||
|
||||
export const GraphTabContent = ({
|
||||
data,
|
||||
stacked,
|
||||
lastQueryParams,
|
||||
}: {
|
||||
data: any;
|
||||
stacked: boolean;
|
||||
lastQueryParams: QueryParams | null;
|
||||
}) => {
|
||||
if (!isPresent(data)) {
|
||||
return <Alert color="light">No data queried yet</Alert>;
|
||||
}
|
||||
if (data.result.length === 0) {
|
||||
return <Alert color="secondary">Empty query result</Alert>;
|
||||
}
|
||||
if (data.resultType !== 'matrix') {
|
||||
return (
|
||||
<Alert color="danger">Query result is of wrong type '{data.resultType}', should be 'matrix' (range vector).</Alert>
|
||||
);
|
||||
}
|
||||
return <Graph data={data} stacked={stacked} queryParams={lastQueryParams} />;
|
||||
};
|
|
@ -3,10 +3,10 @@ import { mount, shallow } from 'enzyme';
|
|||
import Panel, { PanelOptions, PanelType } from './Panel';
|
||||
import ExpressionInput from './ExpressionInput';
|
||||
import GraphControls from './GraphControls';
|
||||
import Graph from './Graph';
|
||||
import { NavLink, TabPane } from 'reactstrap';
|
||||
import TimeInput from './TimeInput';
|
||||
import DataTable from './DataTable';
|
||||
import { GraphTabContent } from './GraphTabContent';
|
||||
|
||||
describe('Panel', () => {
|
||||
const props = {
|
||||
|
@ -83,8 +83,8 @@ describe('Panel', () => {
|
|||
};
|
||||
const graphPanel = mount(<Panel {...props} options={options} />);
|
||||
const controls = graphPanel.find(GraphControls);
|
||||
graphPanel.setState({ data: [] });
|
||||
const graph = graphPanel.find(Graph);
|
||||
graphPanel.setState({ data: { resultType: 'matrix', result: [] } });
|
||||
const graph = graphPanel.find(GraphTabContent);
|
||||
expect(controls.prop('endTime')).toEqual(options.endTime);
|
||||
expect(controls.prop('range')).toEqual(options.range);
|
||||
expect(controls.prop('resolution')).toEqual(options.resolution);
|
||||
|
|
|
@ -6,7 +6,7 @@ import moment from 'moment-timezone';
|
|||
|
||||
import ExpressionInput from './ExpressionInput';
|
||||
import GraphControls from './GraphControls';
|
||||
import Graph from './Graph';
|
||||
import { GraphTabContent } from './GraphTabContent';
|
||||
import DataTable from './DataTable';
|
||||
import TimeInput from './TimeInput';
|
||||
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||
|
@ -287,11 +287,11 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
|
|||
onChangeResolution={this.handleChangeResolution}
|
||||
onChangeStacking={this.handleChangeStacking}
|
||||
/>
|
||||
{this.state.data ? (
|
||||
<Graph data={this.state.data} stacked={options.stacked} queryParams={this.state.lastQueryParams} />
|
||||
) : (
|
||||
<Alert color="light">No data queried yet</Alert>
|
||||
)}
|
||||
<GraphTabContent
|
||||
data={this.state.data}
|
||||
stacked={options.stacked}
|
||||
lastQueryParams={this.state.lastQueryParams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabPane>
|
||||
|
|
4
web/ui/react-app/src/types/index.d.ts
vendored
4
web/ui/react-app/src/types/index.d.ts
vendored
|
@ -1,4 +1,8 @@
|
|||
declare namespace jquery.flot {
|
||||
// eslint-disable-next-line @typescript-eslint/class-name-casing
|
||||
interface plot extends jquery.flot.plot {
|
||||
destroy: () => void;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/class-name-casing
|
||||
interface plotOptions extends jquery.flot.plotOptions {
|
||||
tooltip: {
|
||||
|
|
Loading…
Reference in a new issue