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:
Levi Harrison 2021-06-12 12:02:40 -04:00 committed by GitHub
parent faed8df31d
commit f0fe189d20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 458 additions and 125 deletions

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
</>
)}

View File

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

View File

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

View File

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

View File

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