React UI: Add Exemplar Support to Graph (#8832)
* Added exemplar support Signed-off-by: Levi Harrison <git@leviharrison.dev> * Modified tests Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fix eslint suggestions Signed-off-by: Levi Harrison <git@leviharrison.dev> * Address review comments Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fixed undefined data property error Signed-off-by: Levi Harrison <git@leviharrison.dev> * Added series label section to tooltip Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fixed spacing Signed-off-by: GitHub <noreply@github.com> Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fixed tests Signed-off-by: Levi Harrison <git@leviharrison.dev> * Added exemplar info Signed-off-by: Levi Harrison <git@leviharrison.dev> * Changed exemplar symbol Signed-off-by: Levi Harrison <git@leviharrison.dev> Co-authored-by: Julius Volz <julius.volz@gmail.com> Signed-off-by: Levi Harrison <git@leviharrison.dev> * Hide selected exemplar info when 'Show Exemplars' is unchecked Signed-off-by: Levi Harrison <git@leviharrison.dev> * Include series labels in exemplar info Signed-off-by: Levi Harrison <git@leviharrison.dev> * De-densify exemplars Signed-off-by: Levi Harrison <git@leviharrison.dev> * Moved showExemplars to per-panel control Signed-off-by: Levi Harrison <git@leviharrison.dev> * Eslint fixes Signed-off-by: Levi Harrison <git@leviharrison.dev> * Address review comments Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fixed tests Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fix state bug Signed-off-by: Levi Harrison <git@leviharrison.dev> * Removed unused object Signed-off-by: Levi Harrison <git@leviharrison.dev> * Fix eslint Signed-off-by: Levi Harrison <git@leviharrison.dev> * Encoded 'show_exemplars' in url Signed-off-by: Levi Harrison <git@leviharrison.dev> Co-authored-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
faed8df31d
commit
f0fe189d20
|
@ -54,11 +54,31 @@ describe('Graph', () => {
|
|||
],
|
||||
},
|
||||
],
|
||||
exemplars: [
|
||||
{
|
||||
seriesLabels: {
|
||||
code: '200',
|
||||
handler: '/graph',
|
||||
instance: 'localhost:9090',
|
||||
job: 'prometheus',
|
||||
},
|
||||
exemplars: [
|
||||
{
|
||||
labels: {
|
||||
traceID: '12345',
|
||||
},
|
||||
timestamp: 1572130580,
|
||||
value: '9',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
id: 'test',
|
||||
};
|
||||
it('renders a graph with props', () => {
|
||||
const graph = shallow(<Graph {...props} />);
|
||||
const div = graph.find('div').filterWhere(elem => elem.prop('className') === 'graph');
|
||||
const div = graph.find('div').filterWhere(elem => elem.prop('className') === 'graph-test');
|
||||
const resize = div.find(ReactResizeDetector);
|
||||
const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart');
|
||||
expect(resize.prop('handleWidth')).toBe(true);
|
||||
|
@ -101,14 +121,18 @@ describe('Graph', () => {
|
|||
graph.setProps({ data: { result: [{ values: [{}], metric: {} }] } });
|
||||
expect(spyState).toHaveBeenCalledWith(
|
||||
{
|
||||
chartData: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, null]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
chartData: {
|
||||
exemplars: [],
|
||||
series: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, null]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
stack: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -117,14 +141,18 @@ describe('Graph', () => {
|
|||
graph.setProps({ stacked: false });
|
||||
expect(spyState).toHaveBeenCalledWith(
|
||||
{
|
||||
chartData: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, null]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
chartData: {
|
||||
exemplars: [],
|
||||
series: [
|
||||
{
|
||||
color: 'rgb(237,194,64)',
|
||||
data: [[1572128592000, null]],
|
||||
index: 0,
|
||||
labels: {},
|
||||
stack: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -267,7 +295,7 @@ describe('Graph', () => {
|
|||
);
|
||||
(graph.instance() as any).plot(); // create chart
|
||||
graph.find('.graph-legend').simulate('mouseout');
|
||||
expect(mockSetData).toHaveBeenCalledWith(graph.state().chartData);
|
||||
expect(mockSetData).toHaveBeenCalledWith(graph.state().chartData.series);
|
||||
spyPlot.mockReset();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
|
|||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
import { Legend } from './Legend';
|
||||
import { Metric, QueryParams } from '../../types/types';
|
||||
import { Metric, ExemplarData, QueryParams } from '../../types/types';
|
||||
import { isPresent } from '../../utils';
|
||||
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
||||
|
||||
|
@ -18,9 +18,12 @@ export interface GraphProps {
|
|||
resultType: string;
|
||||
result: Array<{ metric: Metric; values: [number, string][] }>;
|
||||
};
|
||||
exemplars: ExemplarData;
|
||||
stacked: boolean;
|
||||
useLocalTime: boolean;
|
||||
showExemplars: boolean;
|
||||
queryParams: QueryParams | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GraphSeries {
|
||||
|
@ -30,8 +33,22 @@ export interface GraphSeries {
|
|||
index: number;
|
||||
}
|
||||
|
||||
export interface GraphExemplar {
|
||||
seriesLabels: { [key: string]: string };
|
||||
labels: { [key: string]: string };
|
||||
data: number[][];
|
||||
points: any; // This is used to specify the symbol.
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
series: GraphSeries[];
|
||||
exemplars: GraphExemplar[];
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
chartData: GraphSeries[];
|
||||
chartData: GraphData;
|
||||
selectedExemplarLabels: { exemplar: { [key: string]: string }; series: { [key: string]: string } };
|
||||
}
|
||||
|
||||
class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
|
@ -42,10 +59,11 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
|
||||
state = {
|
||||
chartData: normalizeData(this.props),
|
||||
selectedExemplarLabels: { exemplar: {}, series: {} },
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps) {
|
||||
const { data, stacked, useLocalTime } = this.props;
|
||||
const { data, stacked, useLocalTime, showExemplars } = this.props;
|
||||
if (prevProps.data !== data) {
|
||||
this.selectedSeriesIndexes = [];
|
||||
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
||||
|
@ -54,7 +72,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
if (this.selectedSeriesIndexes.length === 0) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.plot(this.state.chartData.filter((_, i) => this.selectedSeriesIndexes.includes(i)));
|
||||
this.plot([
|
||||
...this.state.chartData.series.filter((_, i) => this.selectedSeriesIndexes.includes(i)),
|
||||
...this.state.chartData.exemplars,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -62,17 +83,44 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
if (prevProps.useLocalTime !== useLocalTime) {
|
||||
this.plot();
|
||||
}
|
||||
|
||||
if (prevProps.showExemplars !== showExemplars && !showExemplars) {
|
||||
this.setState(
|
||||
{
|
||||
chartData: { series: this.state.chartData.series, exemplars: [] },
|
||||
selectedExemplarLabels: { exemplar: {}, series: {} },
|
||||
},
|
||||
() => {
|
||||
this.plot();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.plot();
|
||||
|
||||
$(`.graph-${this.props.id}`).bind('plotclick', (event, pos, item) => {
|
||||
// If an item has the series label property that means it's an exemplar.
|
||||
if (item && 'seriesLabels' in item.series) {
|
||||
this.setState({
|
||||
selectedExemplarLabels: { exemplar: item.series.labels, series: item.series.seriesLabels },
|
||||
chartData: this.state.chartData,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
chartData: this.state.chartData,
|
||||
selectedExemplarLabels: { exemplar: {}, series: {} },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyPlot();
|
||||
}
|
||||
|
||||
plot = (data: GraphSeries[] = this.state.chartData) => {
|
||||
plot = (data: (GraphSeries | GraphExemplar)[] = [...this.state.chartData.series, ...this.state.chartData.exemplars]) => {
|
||||
if (!this.chartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
@ -87,7 +135,9 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
}
|
||||
};
|
||||
|
||||
plotSetAndDraw(data: GraphSeries[] = this.state.chartData) {
|
||||
plotSetAndDraw(
|
||||
data: (GraphSeries | GraphExemplar)[] = [...this.state.chartData.series, ...this.state.chartData.exemplars]
|
||||
) {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.$chart.setData(data);
|
||||
this.$chart.draw();
|
||||
|
@ -98,8 +148,21 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
const { chartData } = this.state;
|
||||
this.plot(
|
||||
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
|
||||
? chartData.map(toHoverColor(selectedIndex, this.props.stacked))
|
||||
: chartData.filter((_, i) => selected.includes(i)) // draw only selected
|
||||
? [...chartData.series.map(toHoverColor(selectedIndex, this.props.stacked)), ...chartData.exemplars]
|
||||
: [
|
||||
...chartData.series.filter((_, i) => selected.includes(i)),
|
||||
...chartData.exemplars.filter(exemplar => {
|
||||
series: for (const i in selected) {
|
||||
for (const name in chartData.series[selected[i]].labels) {
|
||||
if (exemplar.seriesLabels[name] !== chartData.series[selected[i]].labels[name]) {
|
||||
continue series;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
] // draw only selected
|
||||
);
|
||||
this.selectedSeriesIndexes = selected;
|
||||
};
|
||||
|
@ -109,7 +172,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
cancelAnimationFrame(this.rafID);
|
||||
}
|
||||
this.rafID = requestAnimationFrame(() => {
|
||||
this.plotSetAndDraw(this.state.chartData.map(toHoverColor(index, this.props.stacked)));
|
||||
this.plotSetAndDraw([
|
||||
...this.state.chartData.series.map(toHoverColor(index, this.props.stacked)),
|
||||
...this.state.chartData.exemplars,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -120,23 +186,49 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
|
||||
handleResize = () => {
|
||||
if (isPresent(this.$chart)) {
|
||||
this.plot(this.$chart.getData() as GraphSeries[]);
|
||||
this.plot(this.$chart.getData() as (GraphSeries | GraphExemplar)[]);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { chartData } = this.state;
|
||||
const { chartData, selectedExemplarLabels } = this.state;
|
||||
const selectedLabels = selectedExemplarLabels as {
|
||||
exemplar: { [key: string]: string };
|
||||
series: { [key: string]: string };
|
||||
};
|
||||
return (
|
||||
<div className="graph">
|
||||
<div className={`graph-${this.props.id}`}>
|
||||
<ReactResizeDetector handleWidth onResize={this.handleResize} skipOnMount />
|
||||
<div className="graph-chart" ref={this.chartRef} />
|
||||
{Object.keys(selectedLabels.exemplar).length > 0 ? (
|
||||
<div className="float-right">
|
||||
<span style={{ fontSize: '17px' }}>Selected exemplar:</span>
|
||||
<div className="labels mt-1">
|
||||
{Object.keys(selectedLabels.exemplar).map((k, i) => (
|
||||
<div key={i} style={{ fontSize: '15px' }}>
|
||||
<strong>{k}</strong>: {selectedLabels.exemplar[k]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: '16px' }}>Series labels:</span>
|
||||
<div className="labels mt-1">
|
||||
{Object.keys(selectedLabels.series).map((k, i) => (
|
||||
<div key={i} style={{ fontSize: '15px' }}>
|
||||
<strong>{k}</strong>: {selectedLabels.series[k]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Legend
|
||||
shouldReset={this.selectedSeriesIndexes.length === 0}
|
||||
chartData={chartData}
|
||||
chartData={chartData.series}
|
||||
onHover={this.handleSeriesHover}
|
||||
onLegendMouseOut={this.handleLegendMouseOut}
|
||||
onSeriesToggle={this.handleSeriesSelect}
|
||||
/>
|
||||
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
|
||||
<br style={{ clear: 'both' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
|
|||
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 { faSquare, faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
const defaultGraphControlProps = {
|
||||
|
@ -11,6 +11,7 @@ const defaultGraphControlProps = {
|
|||
endTime: 1572100217898,
|
||||
resolution: 10,
|
||||
stacked: false,
|
||||
showExemplars: false,
|
||||
|
||||
onChangeRange: (): void => {
|
||||
// Do nothing.
|
||||
|
@ -24,6 +25,9 @@ const defaultGraphControlProps = {
|
|||
onChangeStacking: (): void => {
|
||||
// Do nothing.
|
||||
},
|
||||
onChangeShowExemplars: (): void => {
|
||||
// Do nothing.
|
||||
},
|
||||
};
|
||||
|
||||
describe('GraphControls', () => {
|
||||
|
@ -112,11 +116,16 @@ describe('GraphControls', () => {
|
|||
expect(input.prop('bsSize')).toEqual('sm');
|
||||
});
|
||||
|
||||
it('renders a button group', () => {
|
||||
const controls = shallow(<GraphControls {...defaultGraphControlProps} />);
|
||||
const group = controls.find(ButtonGroup);
|
||||
expect(group.prop('className')).toEqual('stacked-input');
|
||||
expect(group.prop('size')).toEqual('sm');
|
||||
it('renders button groups', () => {
|
||||
[
|
||||
{ className: 'stacked-input', size: 'sm' },
|
||||
{ className: 'show-exemplars', size: 'sm' },
|
||||
].forEach((testCase, i) => {
|
||||
const controls = shallow(<GraphControls {...defaultGraphControlProps} />);
|
||||
const groups = controls.find(ButtonGroup);
|
||||
expect(groups.get(i).props['className']).toEqual(testCase.className);
|
||||
expect(groups.get(i).props['size']).toEqual(testCase.size);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders buttons inside the button group', () => {
|
||||
|
|
|
@ -3,7 +3,6 @@ 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 { parseDuration, formatDuration } from '../../utils';
|
||||
|
||||
|
@ -13,11 +12,13 @@ interface GraphControlsProps {
|
|||
useLocalTime: boolean;
|
||||
resolution: number | null;
|
||||
stacked: boolean;
|
||||
showExemplars: boolean;
|
||||
|
||||
onChangeRange: (range: number) => void;
|
||||
onChangeEndTime: (endTime: number | null) => void;
|
||||
onChangeResolution: (resolution: number | null) => void;
|
||||
onChangeStacking: (stacked: boolean) => void;
|
||||
onChangeShowExemplars: (show: boolean) => void;
|
||||
}
|
||||
|
||||
class GraphControls extends Component<GraphControlsProps> {
|
||||
|
@ -147,6 +148,18 @@ class GraphControls extends Component<GraphControlsProps> {
|
|||
<FontAwesomeIcon icon={faChartArea} fixedWidth />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup className="show-exemplars" size="sm">
|
||||
{this.props.showExemplars ? (
|
||||
<Button title="Hide exemplars" onClick={() => this.props.onChangeShowExemplars(false)} active={true}>
|
||||
Hide Exemplars
|
||||
</Button>
|
||||
) : (
|
||||
<Button title="Show exemplars" onClick={() => this.props.onChangeShowExemplars(true)} active={false}>
|
||||
Show Exemplars
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ describe('GraphHelpers', () => {
|
|||
it('should configure options properly if stacked prop is true', () => {
|
||||
expect(getOptions(true, false)).toMatchObject({
|
||||
series: {
|
||||
stack: true,
|
||||
stack: false,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
|
@ -151,8 +151,7 @@ describe('GraphHelpers', () => {
|
|||
<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>
|
||||
`);
|
||||
</div>`);
|
||||
});
|
||||
it('should return proper tooltip html from options with local time', () => {
|
||||
moment.tz.setDefault('America/New_York');
|
||||
|
@ -166,10 +165,29 @@ describe('GraphHelpers', () => {
|
|||
<span class="detail-swatch" style="background-color: "></span>
|
||||
<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 return proper tooltip for exemplar', () => {
|
||||
expect(
|
||||
getOptions(true, false).tooltip.content('', 1572128592, 1572128592, {
|
||||
series: { labels: { foo: '1', bar: '2' }, seriesLabels: { foo: '2', bar: '3' }, color: '' },
|
||||
} as any)
|
||||
).toEqual(`
|
||||
<div class="date">1970-01-19 04:42:08 +00:00</div>
|
||||
<div>
|
||||
<span class="detail-swatch" style="background-color: "></span>
|
||||
<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>
|
||||
`);
|
||||
|
||||
<span>Series labels:</span>
|
||||
<div class="labels mt-1">
|
||||
<div class="mb-1"><strong>foo</strong>: 2</div><div class="mb-1"><strong>bar</strong>: 3</div>
|
||||
</div>`);
|
||||
});
|
||||
it('should render Plot with proper options', () => {
|
||||
expect(getOptions(true, false)).toEqual({
|
||||
|
@ -196,7 +214,7 @@ describe('GraphHelpers', () => {
|
|||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: true,
|
||||
stack: false,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@ import $ from 'jquery';
|
|||
|
||||
import { escapeHTML } from '../../utils';
|
||||
import { Metric } from '../../types/types';
|
||||
import { GraphProps, GraphSeries } from './Graph';
|
||||
import { GraphProps, GraphData, GraphSeries, GraphExemplar } from './Graph';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export const formatValue = (y: number | null): string => {
|
||||
|
@ -101,7 +101,8 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
|||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: (_, xval, yval, { series }): string => {
|
||||
const { labels, color } = series;
|
||||
const both = series as GraphExemplar | GraphSeries;
|
||||
const { labels, color } = both;
|
||||
let dateTime = moment(xval);
|
||||
if (!useLocalTime) {
|
||||
dateTime = dateTime.utc();
|
||||
|
@ -119,13 +120,29 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
|||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
${
|
||||
'seriesLabels' in both
|
||||
? `
|
||||
<span>Series labels:</span>
|
||||
<div class="labels mt-1">
|
||||
${Object.keys(both.seriesLabels)
|
||||
.map(k =>
|
||||
k !== '__name__'
|
||||
? `<div class="mb-1"><strong>${k}</strong>: ${escapeHTML(both.seriesLabels[k])}</div>`
|
||||
: ''
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`.trimEnd();
|
||||
},
|
||||
defaultTheme: false,
|
||||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: stacked,
|
||||
stack: false, // Stacking is set on a per-series basis because exemplar symbols don't support it.
|
||||
lines: {
|
||||
lineWidth: stacked ? 1 : 2,
|
||||
steps: false,
|
||||
|
@ -161,32 +178,82 @@ export const getColors = (data: { resultType: string; result: Array<{ metric: Me
|
|||
});
|
||||
};
|
||||
|
||||
export const normalizeData = ({ queryParams, data }: GraphProps): GraphSeries[] => {
|
||||
export const normalizeData = ({ queryParams, data, exemplars, stacked }: GraphProps): GraphData => {
|
||||
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])]);
|
||||
pos++;
|
||||
} else {
|
||||
data.push([t * 1000, null]);
|
||||
let sum = 0;
|
||||
const values: number[] = [];
|
||||
// Exemplars are grouped into buckets by time to use for de-densifying.
|
||||
const buckets: { [time: number]: GraphExemplar[] } = {};
|
||||
for (const exemplar of exemplars || []) {
|
||||
for (const { labels, value, timestamp } of exemplar.exemplars) {
|
||||
const parsed = parseValue(value) || 0;
|
||||
sum += parsed;
|
||||
values.push(parsed);
|
||||
|
||||
const bucketTime = Math.floor((timestamp / ((endTime - startTime) / 60)) * 0.8) * 1000;
|
||||
if (!buckets[bucketTime]) {
|
||||
buckets[bucketTime] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colors[index].toString(),
|
||||
data,
|
||||
index,
|
||||
};
|
||||
});
|
||||
buckets[bucketTime].push({
|
||||
seriesLabels: exemplar.seriesLabels,
|
||||
labels: labels,
|
||||
data: [[timestamp * 1000, parsed]],
|
||||
points: { symbol: exemplarSymbol },
|
||||
color: '#0275d8',
|
||||
});
|
||||
}
|
||||
}
|
||||
const deviation = stdDeviation(sum, values);
|
||||
|
||||
return {
|
||||
series: 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])]);
|
||||
pos++;
|
||||
} else {
|
||||
data.push([t * 1000, null]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colors[index].toString(),
|
||||
stack: stacked,
|
||||
data,
|
||||
index,
|
||||
};
|
||||
}),
|
||||
exemplars: Object.values(buckets).flatMap(bucket => {
|
||||
if (bucket.length === 1) {
|
||||
return bucket[0];
|
||||
}
|
||||
return bucket
|
||||
.sort((a, b) => exValue(b) - exValue(a)) // Sort exemplars by value in descending order.
|
||||
.reduce((exemplars: GraphExemplar[], exemplar) => {
|
||||
if (exemplars.length === 0) {
|
||||
exemplars.push(exemplar);
|
||||
} else {
|
||||
const prev = exemplars[exemplars.length - 1];
|
||||
// Don't plot this exemplar if it's less than two times the standard
|
||||
// deviation spaced from the last.
|
||||
if (exValue(prev) - exValue(exemplar) >= 2 * deviation) {
|
||||
exemplars.push(exemplar);
|
||||
}
|
||||
}
|
||||
return exemplars;
|
||||
}, []);
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseValue = (value: string) => {
|
||||
|
@ -195,3 +262,37 @@ export const parseValue = (value: string) => {
|
|||
// can't be graphed, so show them as gaps (null).
|
||||
return isNaN(val) ? null : val;
|
||||
};
|
||||
|
||||
const exemplarSymbol = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
|
||||
// Center the symbol on the point.
|
||||
y = y - 3.5;
|
||||
|
||||
// Correct if the symbol is overflowing off the grid.
|
||||
if (x > ctx.canvas.clientWidth - 59) {
|
||||
x = ctx.canvas.clientWidth - 59;
|
||||
}
|
||||
if (y > ctx.canvas.clientHeight - 40) {
|
||||
y = ctx.canvas.clientHeight - 40;
|
||||
}
|
||||
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
ctx.translate(-x, -y);
|
||||
|
||||
ctx.fillStyle = '#92bce1';
|
||||
ctx.fillRect(x, y, 7, 7);
|
||||
|
||||
ctx.strokeStyle = '#0275d8';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, 7, 7);
|
||||
};
|
||||
|
||||
const stdDeviation = (sum: number, values: number[]): number => {
|
||||
const avg = sum / values.length;
|
||||
let squaredAvg = 0;
|
||||
values.map(value => (squaredAvg += (value - avg) ** 2));
|
||||
squaredAvg = squaredAvg / values.length;
|
||||
return Math.sqrt(squaredAvg);
|
||||
};
|
||||
|
||||
const exValue = (exemplar: GraphExemplar): number => exemplar.data[0][1];
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
import React, { FC } from 'react';
|
||||
import { Alert } from 'reactstrap';
|
||||
import Graph from './Graph';
|
||||
import { QueryParams } from '../../types/types';
|
||||
import { QueryParams, ExemplarData } from '../../types/types';
|
||||
import { isPresent } from '../../utils';
|
||||
|
||||
interface GraphTabContentProps {
|
||||
data: any;
|
||||
exemplars: ExemplarData;
|
||||
stacked: boolean;
|
||||
useLocalTime: boolean;
|
||||
showExemplars: boolean;
|
||||
lastQueryParams: QueryParams | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const GraphTabContent: FC<GraphTabContentProps> = ({ data, stacked, useLocalTime, lastQueryParams }) => {
|
||||
export const GraphTabContent: FC<GraphTabContentProps> = ({
|
||||
data,
|
||||
exemplars,
|
||||
stacked,
|
||||
useLocalTime,
|
||||
lastQueryParams,
|
||||
showExemplars,
|
||||
id,
|
||||
}) => {
|
||||
if (!isPresent(data)) {
|
||||
return <Alert color="light">No data queried yet</Alert>;
|
||||
}
|
||||
|
@ -23,5 +34,15 @@ export const GraphTabContent: FC<GraphTabContentProps> = ({ data, stacked, useLo
|
|||
<Alert color="danger">Query result is of wrong type '{data.resultType}', should be 'matrix' (range vector).</Alert>
|
||||
);
|
||||
}
|
||||
return <Graph data={data} stacked={stacked} useLocalTime={useLocalTime} queryParams={lastQueryParams} />;
|
||||
return (
|
||||
<Graph
|
||||
data={data}
|
||||
exemplars={exemplars}
|
||||
stacked={stacked}
|
||||
useLocalTime={useLocalTime}
|
||||
showExemplars={showExemplars}
|
||||
queryParams={lastQueryParams}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@ import { GraphTabContent } from './GraphTabContent';
|
|||
import DataTable from './DataTable';
|
||||
import TimeInput from './TimeInput';
|
||||
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||
import { QueryParams } from '../../types/types';
|
||||
import { QueryParams, ExemplarData } from '../../types/types';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
|
||||
interface PanelProps {
|
||||
|
@ -27,10 +27,12 @@ interface PanelProps {
|
|||
enableAutocomplete: boolean;
|
||||
enableHighlighting: boolean;
|
||||
enableLinter: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PanelState {
|
||||
data: any; // TODO: Type data.
|
||||
exemplars: ExemplarData;
|
||||
lastQueryParams: QueryParams | null;
|
||||
loading: boolean;
|
||||
warnings: string[] | null;
|
||||
|
@ -46,6 +48,7 @@ export interface PanelOptions {
|
|||
endTime: number | null; // Timestamp in milliseconds.
|
||||
resolution: number | null; // Resolution in seconds.
|
||||
stacked: boolean;
|
||||
showExemplars: boolean;
|
||||
}
|
||||
|
||||
export enum PanelType {
|
||||
|
@ -60,6 +63,7 @@ export const PanelDefaultOptions: PanelOptions = {
|
|||
endTime: null,
|
||||
resolution: null,
|
||||
stacked: false,
|
||||
showExemplars: false,
|
||||
};
|
||||
|
||||
class Panel extends Component<PanelProps, PanelState> {
|
||||
|
@ -70,6 +74,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
|
||||
this.state = {
|
||||
data: null,
|
||||
exemplars: [],
|
||||
lastQueryParams: null,
|
||||
loading: false,
|
||||
warnings: null,
|
||||
|
@ -80,12 +85,13 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
}
|
||||
|
||||
componentDidUpdate({ options: prevOpts }: PanelProps) {
|
||||
const { endTime, range, resolution, type } = this.props.options;
|
||||
const { endTime, range, resolution, showExemplars, type } = this.props.options;
|
||||
if (
|
||||
prevOpts.endTime !== endTime ||
|
||||
prevOpts.range !== range ||
|
||||
prevOpts.resolution !== resolution ||
|
||||
prevOpts.type !== type
|
||||
prevOpts.type !== type ||
|
||||
showExemplars !== prevOpts.showExemplars
|
||||
) {
|
||||
this.executeQuery();
|
||||
}
|
||||
|
@ -95,7 +101,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
this.executeQuery();
|
||||
}
|
||||
|
||||
executeQuery = (): void => {
|
||||
executeQuery = async (): Promise<any> => {
|
||||
const { exprInputValue: expr } = this.state;
|
||||
const queryStart = Date.now();
|
||||
this.props.onExecuteQuery(expr);
|
||||
|
@ -138,55 +144,70 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
throw new Error('Invalid panel type "' + this.props.options.type + '"');
|
||||
}
|
||||
|
||||
fetch(`${this.props.pathPrefix}/${API_PATH}/${path}?${params}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(json => {
|
||||
if (json.status !== 'success') {
|
||||
throw new Error(json.error || 'invalid response JSON');
|
||||
}
|
||||
let query;
|
||||
let exemplars;
|
||||
try {
|
||||
query = await fetch(`${this.props.pathPrefix}/${API_PATH}/${path}?${params}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
signal: abortController.signal,
|
||||
}).then(resp => resp.json());
|
||||
|
||||
let resultSeries = 0;
|
||||
if (json.data) {
|
||||
const { resultType, result } = json.data;
|
||||
if (resultType === 'scalar') {
|
||||
resultSeries = 1;
|
||||
} else if (result && result.length > 0) {
|
||||
resultSeries = result.length;
|
||||
}
|
||||
}
|
||||
if (query.status !== 'success') {
|
||||
throw new Error(query.error || 'invalid response JSON');
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
data: json.data,
|
||||
warnings: json.warnings,
|
||||
lastQueryParams: {
|
||||
startTime,
|
||||
endTime,
|
||||
resolution,
|
||||
},
|
||||
stats: {
|
||||
loadTime: Date.now() - queryStart,
|
||||
resolution,
|
||||
resultSeries,
|
||||
},
|
||||
loading: false,
|
||||
});
|
||||
this.abortInFlightFetch = null;
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') {
|
||||
// Aborts are expected, don't show an error for them.
|
||||
return;
|
||||
if (this.props.options.type === 'graph' && this.props.options.showExemplars) {
|
||||
params.delete('step'); // Not needed for this request.
|
||||
exemplars = await fetch(`${this.props.pathPrefix}/${API_PATH}/query_exemplars?${params}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
signal: abortController.signal,
|
||||
}).then(resp => resp.json());
|
||||
|
||||
if (exemplars.status !== 'success') {
|
||||
throw new Error(exemplars.error || 'invalid response JSON');
|
||||
}
|
||||
this.setState({
|
||||
error: 'Error executing query: ' + error.message,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
|
||||
let resultSeries = 0;
|
||||
if (query.data) {
|
||||
const { resultType, result } = query.data;
|
||||
if (resultType === 'scalar') {
|
||||
resultSeries = 1;
|
||||
} else if (result && result.length > 0) {
|
||||
resultSeries = result.length;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
data: query.data,
|
||||
exemplars: exemplars?.data,
|
||||
warnings: query.warnings,
|
||||
lastQueryParams: {
|
||||
startTime,
|
||||
endTime,
|
||||
resolution,
|
||||
},
|
||||
stats: {
|
||||
loadTime: Date.now() - queryStart,
|
||||
resolution,
|
||||
resultSeries,
|
||||
},
|
||||
loading: false,
|
||||
});
|
||||
this.abortInFlightFetch = null;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Aborts are expected, don't show an error for them.
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
error: 'Error executing query: ' + error.message,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setOptions(opts: object): void {
|
||||
|
@ -230,6 +251,10 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
this.setOptions({ stacked: stacked });
|
||||
};
|
||||
|
||||
handleChangeShowExemplars = (show: boolean) => {
|
||||
this.setOptions({ showExemplars: show });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { pastQueries, metricNames, options } = this.props;
|
||||
return (
|
||||
|
@ -316,16 +341,21 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
useLocalTime={this.props.useLocalTime}
|
||||
resolution={options.resolution}
|
||||
stacked={options.stacked}
|
||||
showExemplars={options.showExemplars}
|
||||
onChangeRange={this.handleChangeRange}
|
||||
onChangeEndTime={this.handleChangeEndTime}
|
||||
onChangeResolution={this.handleChangeResolution}
|
||||
onChangeStacking={this.handleChangeStacking}
|
||||
onChangeShowExemplars={this.handleChangeShowExemplars}
|
||||
/>
|
||||
<GraphTabContent
|
||||
data={this.state.data}
|
||||
exemplars={this.state.exemplars}
|
||||
stacked={options.stacked}
|
||||
useLocalTime={this.props.useLocalTime}
|
||||
showExemplars={options.showExemplars}
|
||||
lastQueryParams={this.state.lastQueryParams}
|
||||
id={this.props.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -90,6 +90,7 @@ export const PanelListContent: FC<PanelListContentProps> = ({
|
|||
pathPrefix={pathPrefix}
|
||||
onExecuteQuery={handleExecuteQuery}
|
||||
key={id}
|
||||
id={id}
|
||||
options={options}
|
||||
onOptionsChanged={opts =>
|
||||
callAll(setPanels, updateURL)(panels.map(p => (id === p.id ? { ...p, options: opts } : p)))
|
||||
|
|
|
@ -4,6 +4,12 @@ export interface Metric {
|
|||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Exemplar {
|
||||
labels: { [key: string]: string };
|
||||
value: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
|
@ -34,3 +40,5 @@ export interface WALReplayData {
|
|||
export interface WALReplayStatus {
|
||||
data?: WALReplayData;
|
||||
}
|
||||
|
||||
export type ExemplarData = Array<{ seriesLabels: Metric; exemplars: Exemplar[] }> | undefined;
|
||||
|
|
|
@ -201,6 +201,9 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
|
|||
case 'stacked':
|
||||
return { stacked: decodedValue === '1' };
|
||||
|
||||
case 'show_exemplars':
|
||||
return { showExemplars: decodedValue === '1' };
|
||||
|
||||
case 'range_input':
|
||||
const range = parseDuration(decodedValue);
|
||||
return isPresent(range) ? { range } : {};
|
||||
|
@ -222,12 +225,13 @@ export const formatParam = (key: string) => (paramName: string, value: number |
|
|||
|
||||
export const toQueryString = ({ key, options }: PanelMeta) => {
|
||||
const formatWithKey = formatParam(key);
|
||||
const { expr, type, stacked, range, endTime, resolution } = options;
|
||||
const { expr, type, stacked, range, endTime, resolution, showExemplars } = options;
|
||||
const time = isPresent(endTime) ? formatTime(endTime) : false;
|
||||
const urlParams = [
|
||||
formatWithKey('expr', expr),
|
||||
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
||||
formatWithKey('stacked', stacked ? 1 : 0),
|
||||
formatWithKey('show_exemplars', showExemplars ? 1 : 0),
|
||||
formatWithKey('range_input', formatDuration(range)),
|
||||
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
|
||||
isPresent(resolution) ? formatWithKey('step_input', resolution) : '',
|
||||
|
@ -240,7 +244,7 @@ export const encodePanelOptionsToQueryString = (panels: PanelMeta[]) => {
|
|||
};
|
||||
|
||||
export const createExpressionLink = (expr: string) => {
|
||||
return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.range_input=1h`;
|
||||
return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.show_exemplars=0.g0.range_input=1h.`;
|
||||
};
|
||||
export const mapObjEntries = <T, key extends keyof T, Z>(
|
||||
o: T,
|
||||
|
|
|
@ -227,7 +227,7 @@ describe('Utils', () => {
|
|||
},
|
||||
];
|
||||
const query =
|
||||
'?g0.expr=rate(node_cpu_seconds_total%7Bmode%3D%22system%22%7D%5B1m%5D)&g0.tab=0&g0.stacked=0&g0.range_input=1h&g0.end_input=2019-10-25%2023%3A37%3A00&g0.moment_input=2019-10-25%2023%3A37%3A00&g1.expr=node_filesystem_avail_bytes&g1.tab=1&g1.stacked=0&g1.range_input=1h';
|
||||
'?g0.expr=rate(node_cpu_seconds_total%7Bmode%3D%22system%22%7D%5B1m%5D)&g0.tab=0&g0.stacked=0&g0.show_exemplars=0&g0.range_input=1h&g0.end_input=2019-10-25%2023%3A37%3A00&g0.moment_input=2019-10-25%2023%3A37%3A00&g1.expr=node_filesystem_avail_bytes&g1.tab=1&g1.stacked=0&g1.show_exemplars=0&g1.range_input=1h';
|
||||
|
||||
describe('decodePanelOptionsFromQueryString', () => {
|
||||
it('returns [] when query is empty', () => {
|
||||
|
@ -291,9 +291,17 @@ describe('Utils', () => {
|
|||
toQueryString({
|
||||
id: 'asdf',
|
||||
key: '0',
|
||||
options: { expr: 'foo', type: PanelType.Graph, stacked: true, range: 0, endTime: null, resolution: 1 },
|
||||
options: {
|
||||
expr: 'foo',
|
||||
type: PanelType.Graph,
|
||||
stacked: true,
|
||||
showExemplars: true,
|
||||
range: 0,
|
||||
endTime: null,
|
||||
resolution: 1,
|
||||
},
|
||||
})
|
||||
).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.range_input=0s&g0.step_input=1');
|
||||
).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.show_exemplars=1&g0.range_input=0s&g0.step_input=1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue