mirror of
https://github.com/prometheus/prometheus.git
synced 2025-03-05 20:59:13 -08:00
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', () => {
|
it('renders a graph with props', () => {
|
||||||
const graph = shallow(<Graph {...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 resize = div.find(ReactResizeDetector);
|
||||||
const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart');
|
const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart');
|
||||||
expect(resize.prop('handleWidth')).toBe(true);
|
expect(resize.prop('handleWidth')).toBe(true);
|
||||||
|
@ -101,15 +121,19 @@ describe('Graph', () => {
|
||||||
graph.setProps({ data: { result: [{ values: [{}], metric: {} }] } });
|
graph.setProps({ data: { result: [{ values: [{}], metric: {} }] } });
|
||||||
expect(spyState).toHaveBeenCalledWith(
|
expect(spyState).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
chartData: [
|
chartData: {
|
||||||
|
exemplars: [],
|
||||||
|
series: [
|
||||||
{
|
{
|
||||||
color: 'rgb(237,194,64)',
|
color: 'rgb(237,194,64)',
|
||||||
data: [[1572128592000, null]],
|
data: [[1572128592000, null]],
|
||||||
index: 0,
|
index: 0,
|
||||||
labels: {},
|
labels: {},
|
||||||
|
stack: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -117,15 +141,19 @@ describe('Graph', () => {
|
||||||
graph.setProps({ stacked: false });
|
graph.setProps({ stacked: false });
|
||||||
expect(spyState).toHaveBeenCalledWith(
|
expect(spyState).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
chartData: [
|
chartData: {
|
||||||
|
exemplars: [],
|
||||||
|
series: [
|
||||||
{
|
{
|
||||||
color: 'rgb(237,194,64)',
|
color: 'rgb(237,194,64)',
|
||||||
data: [[1572128592000, null]],
|
data: [[1572128592000, null]],
|
||||||
index: 0,
|
index: 0,
|
||||||
labels: {},
|
labels: {},
|
||||||
|
stack: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -267,7 +295,7 @@ describe('Graph', () => {
|
||||||
);
|
);
|
||||||
(graph.instance() as any).plot(); // create chart
|
(graph.instance() as any).plot(); // create chart
|
||||||
graph.find('.graph-legend').simulate('mouseout');
|
graph.find('.graph-legend').simulate('mouseout');
|
||||||
expect(mockSetData).toHaveBeenCalledWith(graph.state().chartData);
|
expect(mockSetData).toHaveBeenCalledWith(graph.state().chartData.series);
|
||||||
spyPlot.mockReset();
|
spyPlot.mockReset();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
|
||||||
import ReactResizeDetector from 'react-resize-detector';
|
import ReactResizeDetector from 'react-resize-detector';
|
||||||
|
|
||||||
import { Legend } from './Legend';
|
import { Legend } from './Legend';
|
||||||
import { Metric, QueryParams } from '../../types/types';
|
import { Metric, ExemplarData, QueryParams } from '../../types/types';
|
||||||
import { isPresent } from '../../utils';
|
import { isPresent } from '../../utils';
|
||||||
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
||||||
|
|
||||||
|
@ -18,9 +18,12 @@ export interface GraphProps {
|
||||||
resultType: string;
|
resultType: string;
|
||||||
result: Array<{ metric: Metric; values: [number, string][] }>;
|
result: Array<{ metric: Metric; values: [number, string][] }>;
|
||||||
};
|
};
|
||||||
|
exemplars: ExemplarData;
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
|
showExemplars: boolean;
|
||||||
queryParams: QueryParams | null;
|
queryParams: QueryParams | null;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphSeries {
|
export interface GraphSeries {
|
||||||
|
@ -30,8 +33,22 @@ export interface GraphSeries {
|
||||||
index: number;
|
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 {
|
interface GraphState {
|
||||||
chartData: GraphSeries[];
|
chartData: GraphData;
|
||||||
|
selectedExemplarLabels: { exemplar: { [key: string]: string }; series: { [key: string]: string } };
|
||||||
}
|
}
|
||||||
|
|
||||||
class Graph extends PureComponent<GraphProps, GraphState> {
|
class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
|
@ -42,10 +59,11 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
chartData: normalizeData(this.props),
|
chartData: normalizeData(this.props),
|
||||||
|
selectedExemplarLabels: { exemplar: {}, series: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate(prevProps: GraphProps) {
|
componentDidUpdate(prevProps: GraphProps) {
|
||||||
const { data, stacked, useLocalTime } = this.props;
|
const { data, stacked, useLocalTime, showExemplars } = this.props;
|
||||||
if (prevProps.data !== data) {
|
if (prevProps.data !== data) {
|
||||||
this.selectedSeriesIndexes = [];
|
this.selectedSeriesIndexes = [];
|
||||||
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
||||||
|
@ -54,7 +72,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
if (this.selectedSeriesIndexes.length === 0) {
|
if (this.selectedSeriesIndexes.length === 0) {
|
||||||
this.plot();
|
this.plot();
|
||||||
} else {
|
} 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) {
|
if (prevProps.useLocalTime !== useLocalTime) {
|
||||||
this.plot();
|
this.plot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevProps.showExemplars !== showExemplars && !showExemplars) {
|
||||||
|
this.setState(
|
||||||
|
{
|
||||||
|
chartData: { series: this.state.chartData.series, exemplars: [] },
|
||||||
|
selectedExemplarLabels: { exemplar: {}, series: {} },
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.plot();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.plot();
|
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() {
|
componentWillUnmount() {
|
||||||
this.destroyPlot();
|
this.destroyPlot();
|
||||||
}
|
}
|
||||||
|
|
||||||
plot = (data: GraphSeries[] = this.state.chartData) => {
|
plot = (data: (GraphSeries | GraphExemplar)[] = [...this.state.chartData.series, ...this.state.chartData.exemplars]) => {
|
||||||
if (!this.chartRef.current) {
|
if (!this.chartRef.current) {
|
||||||
return;
|
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)) {
|
if (isPresent(this.$chart)) {
|
||||||
this.$chart.setData(data);
|
this.$chart.setData(data);
|
||||||
this.$chart.draw();
|
this.$chart.draw();
|
||||||
|
@ -98,8 +148,21 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
const { chartData } = this.state;
|
const { chartData } = this.state;
|
||||||
this.plot(
|
this.plot(
|
||||||
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
|
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
|
||||||
? chartData.map(toHoverColor(selectedIndex, this.props.stacked))
|
? [...chartData.series.map(toHoverColor(selectedIndex, this.props.stacked)), ...chartData.exemplars]
|
||||||
: chartData.filter((_, i) => selected.includes(i)) // draw only selected
|
: [
|
||||||
|
...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;
|
this.selectedSeriesIndexes = selected;
|
||||||
};
|
};
|
||||||
|
@ -109,7 +172,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
cancelAnimationFrame(this.rafID);
|
cancelAnimationFrame(this.rafID);
|
||||||
}
|
}
|
||||||
this.rafID = requestAnimationFrame(() => {
|
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 = () => {
|
handleResize = () => {
|
||||||
if (isPresent(this.$chart)) {
|
if (isPresent(this.$chart)) {
|
||||||
this.plot(this.$chart.getData() as GraphSeries[]);
|
this.plot(this.$chart.getData() as (GraphSeries | GraphExemplar)[]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { chartData } = this.state;
|
const { chartData, selectedExemplarLabels } = this.state;
|
||||||
|
const selectedLabels = selectedExemplarLabels as {
|
||||||
|
exemplar: { [key: string]: string };
|
||||||
|
series: { [key: string]: string };
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="graph">
|
<div className={`graph-${this.props.id}`}>
|
||||||
<ReactResizeDetector handleWidth onResize={this.handleResize} skipOnMount />
|
<ReactResizeDetector handleWidth onResize={this.handleResize} skipOnMount />
|
||||||
<div className="graph-chart" ref={this.chartRef} />
|
<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
|
<Legend
|
||||||
shouldReset={this.selectedSeriesIndexes.length === 0}
|
shouldReset={this.selectedSeriesIndexes.length === 0}
|
||||||
chartData={chartData}
|
chartData={chartData.series}
|
||||||
onHover={this.handleSeriesHover}
|
onHover={this.handleSeriesHover}
|
||||||
onLegendMouseOut={this.handleLegendMouseOut}
|
onLegendMouseOut={this.handleLegendMouseOut}
|
||||||
onSeriesToggle={this.handleSeriesSelect}
|
onSeriesToggle={this.handleSeriesSelect}
|
||||||
/>
|
/>
|
||||||
|
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
|
||||||
|
<br style={{ clear: 'both' }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
|
||||||
import GraphControls from './GraphControls';
|
import GraphControls from './GraphControls';
|
||||||
import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'reactstrap';
|
import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
const defaultGraphControlProps = {
|
const defaultGraphControlProps = {
|
||||||
|
@ -11,6 +11,7 @@ const defaultGraphControlProps = {
|
||||||
endTime: 1572100217898,
|
endTime: 1572100217898,
|
||||||
resolution: 10,
|
resolution: 10,
|
||||||
stacked: false,
|
stacked: false,
|
||||||
|
showExemplars: false,
|
||||||
|
|
||||||
onChangeRange: (): void => {
|
onChangeRange: (): void => {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
|
@ -24,6 +25,9 @@ const defaultGraphControlProps = {
|
||||||
onChangeStacking: (): void => {
|
onChangeStacking: (): void => {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
},
|
},
|
||||||
|
onChangeShowExemplars: (): void => {
|
||||||
|
// Do nothing.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GraphControls', () => {
|
describe('GraphControls', () => {
|
||||||
|
@ -112,11 +116,16 @@ describe('GraphControls', () => {
|
||||||
expect(input.prop('bsSize')).toEqual('sm');
|
expect(input.prop('bsSize')).toEqual('sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a button group', () => {
|
it('renders button groups', () => {
|
||||||
|
[
|
||||||
|
{ className: 'stacked-input', size: 'sm' },
|
||||||
|
{ className: 'show-exemplars', size: 'sm' },
|
||||||
|
].forEach((testCase, i) => {
|
||||||
const controls = shallow(<GraphControls {...defaultGraphControlProps} />);
|
const controls = shallow(<GraphControls {...defaultGraphControlProps} />);
|
||||||
const group = controls.find(ButtonGroup);
|
const groups = controls.find(ButtonGroup);
|
||||||
expect(group.prop('className')).toEqual('stacked-input');
|
expect(groups.get(i).props['className']).toEqual(testCase.className);
|
||||||
expect(group.prop('size')).toEqual('sm');
|
expect(groups.get(i).props['size']).toEqual(testCase.size);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders buttons inside the button group', () => {
|
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
import { parseDuration, formatDuration } from '../../utils';
|
import { parseDuration, formatDuration } from '../../utils';
|
||||||
|
|
||||||
|
@ -13,11 +12,13 @@ interface GraphControlsProps {
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
resolution: number | null;
|
resolution: number | null;
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
|
showExemplars: boolean;
|
||||||
|
|
||||||
onChangeRange: (range: number) => void;
|
onChangeRange: (range: number) => void;
|
||||||
onChangeEndTime: (endTime: number | null) => void;
|
onChangeEndTime: (endTime: number | null) => void;
|
||||||
onChangeResolution: (resolution: number | null) => void;
|
onChangeResolution: (resolution: number | null) => void;
|
||||||
onChangeStacking: (stacked: boolean) => void;
|
onChangeStacking: (stacked: boolean) => void;
|
||||||
|
onChangeShowExemplars: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GraphControls extends Component<GraphControlsProps> {
|
class GraphControls extends Component<GraphControlsProps> {
|
||||||
|
@ -147,6 +148,18 @@ class GraphControls extends Component<GraphControlsProps> {
|
||||||
<FontAwesomeIcon icon={faChartArea} fixedWidth />
|
<FontAwesomeIcon icon={faChartArea} fixedWidth />
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</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>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,7 +102,7 @@ describe('GraphHelpers', () => {
|
||||||
it('should configure options properly if stacked prop is true', () => {
|
it('should configure options properly if stacked prop is true', () => {
|
||||||
expect(getOptions(true, false)).toMatchObject({
|
expect(getOptions(true, false)).toMatchObject({
|
||||||
series: {
|
series: {
|
||||||
stack: true,
|
stack: false,
|
||||||
lines: { lineWidth: 1, steps: false, fill: true },
|
lines: { lineWidth: 1, steps: false, fill: true },
|
||||||
shadowSize: 0,
|
shadowSize: 0,
|
||||||
},
|
},
|
||||||
|
@ -151,8 +151,7 @@ describe('GraphHelpers', () => {
|
||||||
<div>
|
<div>
|
||||||
<div class="labels mt-1">
|
<div class="labels mt-1">
|
||||||
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
|
<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', () => {
|
it('should return proper tooltip html from options with local time', () => {
|
||||||
moment.tz.setDefault('America/New_York');
|
moment.tz.setDefault('America/New_York');
|
||||||
|
@ -166,10 +165,29 @@ describe('GraphHelpers', () => {
|
||||||
<span class="detail-swatch" style="background-color: "></span>
|
<span class="detail-swatch" style="background-color: "></span>
|
||||||
<span>value: <strong>1572128592</strong></span>
|
<span>value: <strong>1572128592</strong></span>
|
||||||
<div>
|
<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="labels mt-1">
|
||||||
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
|
<div class="mb-1"><strong>foo</strong>: 1</div><div class="mb-1"><strong>bar</strong>: 2</div>
|
||||||
</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', () => {
|
it('should render Plot with proper options', () => {
|
||||||
expect(getOptions(true, false)).toEqual({
|
expect(getOptions(true, false)).toEqual({
|
||||||
|
@ -196,7 +214,7 @@ describe('GraphHelpers', () => {
|
||||||
lines: true,
|
lines: true,
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
stack: true,
|
stack: false,
|
||||||
lines: { lineWidth: 1, steps: false, fill: true },
|
lines: { lineWidth: 1, steps: false, fill: true },
|
||||||
shadowSize: 0,
|
shadowSize: 0,
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,7 +2,7 @@ import $ from 'jquery';
|
||||||
|
|
||||||
import { escapeHTML } from '../../utils';
|
import { escapeHTML } from '../../utils';
|
||||||
import { Metric } from '../../types/types';
|
import { Metric } from '../../types/types';
|
||||||
import { GraphProps, GraphSeries } from './Graph';
|
import { GraphProps, GraphData, GraphSeries, GraphExemplar } from './Graph';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
export const formatValue = (y: number | null): string => {
|
export const formatValue = (y: number | null): string => {
|
||||||
|
@ -101,7 +101,8 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
||||||
show: true,
|
show: true,
|
||||||
cssClass: 'graph-tooltip',
|
cssClass: 'graph-tooltip',
|
||||||
content: (_, xval, yval, { series }): string => {
|
content: (_, xval, yval, { series }): string => {
|
||||||
const { labels, color } = series;
|
const both = series as GraphExemplar | GraphSeries;
|
||||||
|
const { labels, color } = both;
|
||||||
let dateTime = moment(xval);
|
let dateTime = moment(xval);
|
||||||
if (!useLocalTime) {
|
if (!useLocalTime) {
|
||||||
dateTime = dateTime.utc();
|
dateTime = dateTime.utc();
|
||||||
|
@ -119,13 +120,29 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
</div>
|
</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,
|
defaultTheme: false,
|
||||||
lines: true,
|
lines: true,
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
stack: stacked,
|
stack: false, // Stacking is set on a per-series basis because exemplar symbols don't support it.
|
||||||
lines: {
|
lines: {
|
||||||
lineWidth: stacked ? 1 : 2,
|
lineWidth: stacked ? 1 : 2,
|
||||||
steps: false,
|
steps: false,
|
||||||
|
@ -161,10 +178,38 @@ 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 colors = getColors(data);
|
||||||
const { startTime, endTime, resolution } = queryParams!;
|
const { startTime, endTime, resolution } = queryParams!;
|
||||||
return data.result.map(({ values, metric }, index) => {
|
|
||||||
|
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] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// Insert nulls for all missing steps.
|
||||||
const data = [];
|
const data = [];
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
|
@ -183,10 +228,32 @@ export const normalizeData = ({ queryParams, data }: GraphProps): GraphSeries[]
|
||||||
return {
|
return {
|
||||||
labels: metric !== null ? metric : {},
|
labels: metric !== null ? metric : {},
|
||||||
color: colors[index].toString(),
|
color: colors[index].toString(),
|
||||||
|
stack: stacked,
|
||||||
data,
|
data,
|
||||||
index,
|
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) => {
|
export const parseValue = (value: string) => {
|
||||||
|
@ -195,3 +262,37 @@ export const parseValue = (value: string) => {
|
||||||
// can't be graphed, so show them as gaps (null).
|
// can't be graphed, so show them as gaps (null).
|
||||||
return isNaN(val) ? null : val;
|
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 React, { FC } from 'react';
|
||||||
import { Alert } from 'reactstrap';
|
import { Alert } from 'reactstrap';
|
||||||
import Graph from './Graph';
|
import Graph from './Graph';
|
||||||
import { QueryParams } from '../../types/types';
|
import { QueryParams, ExemplarData } from '../../types/types';
|
||||||
import { isPresent } from '../../utils';
|
import { isPresent } from '../../utils';
|
||||||
|
|
||||||
interface GraphTabContentProps {
|
interface GraphTabContentProps {
|
||||||
data: any;
|
data: any;
|
||||||
|
exemplars: ExemplarData;
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
|
showExemplars: boolean;
|
||||||
lastQueryParams: QueryParams | null;
|
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)) {
|
if (!isPresent(data)) {
|
||||||
return <Alert color="light">No data queried yet</Alert>;
|
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>
|
<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 DataTable from './DataTable';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||||
import { QueryParams } from '../../types/types';
|
import { QueryParams, ExemplarData } from '../../types/types';
|
||||||
import { API_PATH } from '../../constants/constants';
|
import { API_PATH } from '../../constants/constants';
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
|
@ -27,10 +27,12 @@ interface PanelProps {
|
||||||
enableAutocomplete: boolean;
|
enableAutocomplete: boolean;
|
||||||
enableHighlighting: boolean;
|
enableHighlighting: boolean;
|
||||||
enableLinter: boolean;
|
enableLinter: boolean;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PanelState {
|
interface PanelState {
|
||||||
data: any; // TODO: Type data.
|
data: any; // TODO: Type data.
|
||||||
|
exemplars: ExemplarData;
|
||||||
lastQueryParams: QueryParams | null;
|
lastQueryParams: QueryParams | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
warnings: string[] | null;
|
warnings: string[] | null;
|
||||||
|
@ -46,6 +48,7 @@ export interface PanelOptions {
|
||||||
endTime: number | null; // Timestamp in milliseconds.
|
endTime: number | null; // Timestamp in milliseconds.
|
||||||
resolution: number | null; // Resolution in seconds.
|
resolution: number | null; // Resolution in seconds.
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
|
showExemplars: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PanelType {
|
export enum PanelType {
|
||||||
|
@ -60,6 +63,7 @@ export const PanelDefaultOptions: PanelOptions = {
|
||||||
endTime: null,
|
endTime: null,
|
||||||
resolution: null,
|
resolution: null,
|
||||||
stacked: false,
|
stacked: false,
|
||||||
|
showExemplars: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
class Panel extends Component<PanelProps, PanelState> {
|
class Panel extends Component<PanelProps, PanelState> {
|
||||||
|
@ -70,6 +74,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
data: null,
|
data: null,
|
||||||
|
exemplars: [],
|
||||||
lastQueryParams: null,
|
lastQueryParams: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
warnings: null,
|
warnings: null,
|
||||||
|
@ -80,12 +85,13 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate({ options: prevOpts }: PanelProps) {
|
componentDidUpdate({ options: prevOpts }: PanelProps) {
|
||||||
const { endTime, range, resolution, type } = this.props.options;
|
const { endTime, range, resolution, showExemplars, type } = this.props.options;
|
||||||
if (
|
if (
|
||||||
prevOpts.endTime !== endTime ||
|
prevOpts.endTime !== endTime ||
|
||||||
prevOpts.range !== range ||
|
prevOpts.range !== range ||
|
||||||
prevOpts.resolution !== resolution ||
|
prevOpts.resolution !== resolution ||
|
||||||
prevOpts.type !== type
|
prevOpts.type !== type ||
|
||||||
|
showExemplars !== prevOpts.showExemplars
|
||||||
) {
|
) {
|
||||||
this.executeQuery();
|
this.executeQuery();
|
||||||
}
|
}
|
||||||
|
@ -95,7 +101,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
this.executeQuery();
|
this.executeQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
executeQuery = (): void => {
|
executeQuery = async (): Promise<any> => {
|
||||||
const { exprInputValue: expr } = this.state;
|
const { exprInputValue: expr } = this.state;
|
||||||
const queryStart = Date.now();
|
const queryStart = Date.now();
|
||||||
this.props.onExecuteQuery(expr);
|
this.props.onExecuteQuery(expr);
|
||||||
|
@ -138,20 +144,35 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
throw new Error('Invalid panel type "' + this.props.options.type + '"');
|
throw new Error('Invalid panel type "' + this.props.options.type + '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`${this.props.pathPrefix}/${API_PATH}/${path}?${params}`, {
|
let query;
|
||||||
|
let exemplars;
|
||||||
|
try {
|
||||||
|
query = await fetch(`${this.props.pathPrefix}/${API_PATH}/${path}?${params}`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
}).then(resp => resp.json());
|
||||||
.then(resp => resp.json())
|
|
||||||
.then(json => {
|
if (query.status !== 'success') {
|
||||||
if (json.status !== 'success') {
|
throw new Error(query.error || 'invalid response JSON');
|
||||||
throw new Error(json.error || 'invalid response JSON');
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resultSeries = 0;
|
let resultSeries = 0;
|
||||||
if (json.data) {
|
if (query.data) {
|
||||||
const { resultType, result } = json.data;
|
const { resultType, result } = query.data;
|
||||||
if (resultType === 'scalar') {
|
if (resultType === 'scalar') {
|
||||||
resultSeries = 1;
|
resultSeries = 1;
|
||||||
} else if (result && result.length > 0) {
|
} else if (result && result.length > 0) {
|
||||||
|
@ -161,8 +182,9 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
error: null,
|
error: null,
|
||||||
data: json.data,
|
data: query.data,
|
||||||
warnings: json.warnings,
|
exemplars: exemplars?.data,
|
||||||
|
warnings: query.warnings,
|
||||||
lastQueryParams: {
|
lastQueryParams: {
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
@ -176,8 +198,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
this.abortInFlightFetch = null;
|
this.abortInFlightFetch = null;
|
||||||
})
|
} catch (error) {
|
||||||
.catch(error => {
|
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
// Aborts are expected, don't show an error for them.
|
// Aborts are expected, don't show an error for them.
|
||||||
return;
|
return;
|
||||||
|
@ -186,7 +207,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
error: 'Error executing query: ' + error.message,
|
error: 'Error executing query: ' + error.message,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setOptions(opts: object): void {
|
setOptions(opts: object): void {
|
||||||
|
@ -230,6 +251,10 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
this.setOptions({ stacked: stacked });
|
this.setOptions({ stacked: stacked });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleChangeShowExemplars = (show: boolean) => {
|
||||||
|
this.setOptions({ showExemplars: show });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { pastQueries, metricNames, options } = this.props;
|
const { pastQueries, metricNames, options } = this.props;
|
||||||
return (
|
return (
|
||||||
|
@ -316,16 +341,21 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
useLocalTime={this.props.useLocalTime}
|
useLocalTime={this.props.useLocalTime}
|
||||||
resolution={options.resolution}
|
resolution={options.resolution}
|
||||||
stacked={options.stacked}
|
stacked={options.stacked}
|
||||||
|
showExemplars={options.showExemplars}
|
||||||
onChangeRange={this.handleChangeRange}
|
onChangeRange={this.handleChangeRange}
|
||||||
onChangeEndTime={this.handleChangeEndTime}
|
onChangeEndTime={this.handleChangeEndTime}
|
||||||
onChangeResolution={this.handleChangeResolution}
|
onChangeResolution={this.handleChangeResolution}
|
||||||
onChangeStacking={this.handleChangeStacking}
|
onChangeStacking={this.handleChangeStacking}
|
||||||
|
onChangeShowExemplars={this.handleChangeShowExemplars}
|
||||||
/>
|
/>
|
||||||
<GraphTabContent
|
<GraphTabContent
|
||||||
data={this.state.data}
|
data={this.state.data}
|
||||||
|
exemplars={this.state.exemplars}
|
||||||
stacked={options.stacked}
|
stacked={options.stacked}
|
||||||
useLocalTime={this.props.useLocalTime}
|
useLocalTime={this.props.useLocalTime}
|
||||||
|
showExemplars={options.showExemplars}
|
||||||
lastQueryParams={this.state.lastQueryParams}
|
lastQueryParams={this.state.lastQueryParams}
|
||||||
|
id={this.props.id}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -90,6 +90,7 @@ export const PanelListContent: FC<PanelListContentProps> = ({
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
onExecuteQuery={handleExecuteQuery}
|
onExecuteQuery={handleExecuteQuery}
|
||||||
key={id}
|
key={id}
|
||||||
|
id={id}
|
||||||
options={options}
|
options={options}
|
||||||
onOptionsChanged={opts =>
|
onOptionsChanged={opts =>
|
||||||
callAll(setPanels, updateURL)(panels.map(p => (id === p.id ? { ...p, options: opts } : p)))
|
callAll(setPanels, updateURL)(panels.map(p => (id === p.id ? { ...p, options: opts } : p)))
|
||||||
|
|
|
@ -4,6 +4,12 @@ export interface Metric {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Exemplar {
|
||||||
|
labels: { [key: string]: string };
|
||||||
|
value: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueryParams {
|
export interface QueryParams {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
|
@ -34,3 +40,5 @@ export interface WALReplayData {
|
||||||
export interface WALReplayStatus {
|
export interface WALReplayStatus {
|
||||||
data?: WALReplayData;
|
data?: WALReplayData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExemplarData = Array<{ seriesLabels: Metric; exemplars: Exemplar[] }> | undefined;
|
||||||
|
|
|
@ -201,6 +201,9 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
|
||||||
case 'stacked':
|
case 'stacked':
|
||||||
return { stacked: decodedValue === '1' };
|
return { stacked: decodedValue === '1' };
|
||||||
|
|
||||||
|
case 'show_exemplars':
|
||||||
|
return { showExemplars: decodedValue === '1' };
|
||||||
|
|
||||||
case 'range_input':
|
case 'range_input':
|
||||||
const range = parseDuration(decodedValue);
|
const range = parseDuration(decodedValue);
|
||||||
return isPresent(range) ? { range } : {};
|
return isPresent(range) ? { range } : {};
|
||||||
|
@ -222,12 +225,13 @@ export const formatParam = (key: string) => (paramName: string, value: number |
|
||||||
|
|
||||||
export const toQueryString = ({ key, options }: PanelMeta) => {
|
export const toQueryString = ({ key, options }: PanelMeta) => {
|
||||||
const formatWithKey = formatParam(key);
|
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 time = isPresent(endTime) ? formatTime(endTime) : false;
|
||||||
const urlParams = [
|
const urlParams = [
|
||||||
formatWithKey('expr', expr),
|
formatWithKey('expr', expr),
|
||||||
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
||||||
formatWithKey('stacked', stacked ? 1 : 0),
|
formatWithKey('stacked', stacked ? 1 : 0),
|
||||||
|
formatWithKey('show_exemplars', showExemplars ? 1 : 0),
|
||||||
formatWithKey('range_input', formatDuration(range)),
|
formatWithKey('range_input', formatDuration(range)),
|
||||||
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
|
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
|
||||||
isPresent(resolution) ? formatWithKey('step_input', resolution) : '',
|
isPresent(resolution) ? formatWithKey('step_input', resolution) : '',
|
||||||
|
@ -240,7 +244,7 @@ export const encodePanelOptionsToQueryString = (panels: PanelMeta[]) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createExpressionLink = (expr: string) => {
|
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>(
|
export const mapObjEntries = <T, key extends keyof T, Z>(
|
||||||
o: T,
|
o: T,
|
||||||
|
|
|
@ -227,7 +227,7 @@ describe('Utils', () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const query =
|
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', () => {
|
describe('decodePanelOptionsFromQueryString', () => {
|
||||||
it('returns [] when query is empty', () => {
|
it('returns [] when query is empty', () => {
|
||||||
|
@ -291,9 +291,17 @@ describe('Utils', () => {
|
||||||
toQueryString({
|
toQueryString({
|
||||||
id: 'asdf',
|
id: 'asdf',
|
||||||
key: '0',
|
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 a new issue