From f0fe189d20b78a85fd24b038557e5149e49abe86 Mon Sep 17 00:00:00 2001 From: Levi Harrison Date: Sat, 12 Jun 2021 12:02:40 -0400 Subject: [PATCH] React UI: Add Exemplar Support to Graph (#8832) * Added exemplar support Signed-off-by: Levi Harrison * Modified tests Signed-off-by: Levi Harrison * Fix eslint suggestions Signed-off-by: Levi Harrison * Address review comments Signed-off-by: Levi Harrison * Fixed undefined data property error Signed-off-by: Levi Harrison * Added series label section to tooltip Signed-off-by: Levi Harrison * Fixed spacing Signed-off-by: GitHub Signed-off-by: Levi Harrison * Fixed tests Signed-off-by: Levi Harrison * Added exemplar info Signed-off-by: Levi Harrison * Changed exemplar symbol Signed-off-by: Levi Harrison Co-authored-by: Julius Volz Signed-off-by: Levi Harrison * Hide selected exemplar info when 'Show Exemplars' is unchecked Signed-off-by: Levi Harrison * Include series labels in exemplar info Signed-off-by: Levi Harrison * De-densify exemplars Signed-off-by: Levi Harrison * Moved showExemplars to per-panel control Signed-off-by: Levi Harrison * Eslint fixes Signed-off-by: Levi Harrison * Address review comments Signed-off-by: Levi Harrison * Fixed tests Signed-off-by: Levi Harrison * Fix state bug Signed-off-by: Levi Harrison * Removed unused object Signed-off-by: Levi Harrison * Fix eslint Signed-off-by: Levi Harrison * Encoded 'show_exemplars' in url Signed-off-by: Levi Harrison Co-authored-by: Julius Volz --- .../react-app/src/pages/graph/Graph.test.tsx | 64 +++++--- web/ui/react-app/src/pages/graph/Graph.tsx | 118 ++++++++++++-- .../src/pages/graph/GraphControls.test.tsx | 21 ++- .../src/pages/graph/GraphControls.tsx | 15 +- .../src/pages/graph/GraphHelpers.test.ts | 28 +++- .../react-app/src/pages/graph/GraphHelpers.ts | 151 +++++++++++++++--- .../src/pages/graph/GraphTabContent.tsx | 27 +++- web/ui/react-app/src/pages/graph/Panel.tsx | 128 +++++++++------ .../react-app/src/pages/graph/PanelList.tsx | 1 + web/ui/react-app/src/types/types.ts | 8 + web/ui/react-app/src/utils/index.ts | 8 +- web/ui/react-app/src/utils/utils.test.ts | 14 +- 12 files changed, 458 insertions(+), 125 deletions(-) diff --git a/web/ui/react-app/src/pages/graph/Graph.test.tsx b/web/ui/react-app/src/pages/graph/Graph.test.tsx index bddd639462..b5bdc93b81 100644 --- a/web/ui/react-app/src/pages/graph/Graph.test.tsx +++ b/web/ui/react-app/src/pages/graph/Graph.test.tsx @@ -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(); - 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(); }); }); diff --git a/web/ui/react-app/src/pages/graph/Graph.tsx b/web/ui/react-app/src/pages/graph/Graph.tsx index 09b15c8252..4e09c2b91b 100644 --- a/web/ui/react-app/src/pages/graph/Graph.tsx +++ b/web/ui/react-app/src/pages/graph/Graph.tsx @@ -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 { @@ -42,10 +59,11 @@ class Graph extends PureComponent { 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 { 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 { 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 { } }; - 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 { 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 { 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 { 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 ( -
+
+ {Object.keys(selectedLabels.exemplar).length > 0 ? ( +
+ Selected exemplar: +
+ {Object.keys(selectedLabels.exemplar).map((k, i) => ( +
+ {k}: {selectedLabels.exemplar[k]} +
+ ))} +
+ Series labels: +
+ {Object.keys(selectedLabels.series).map((k, i) => ( +
+ {k}: {selectedLabels.series[k]} +
+ ))} +
+
+ ) : null} + {/* This is to make sure the graph box expands when the selected exemplar info pops up. */} +
); } diff --git a/web/ui/react-app/src/pages/graph/GraphControls.test.tsx b/web/ui/react-app/src/pages/graph/GraphControls.test.tsx index 25a3bdecd4..5191961159 100755 --- a/web/ui/react-app/src/pages/graph/GraphControls.test.tsx +++ b/web/ui/react-app/src/pages/graph/GraphControls.test.tsx @@ -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(); - 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(); + 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', () => { diff --git a/web/ui/react-app/src/pages/graph/GraphControls.tsx b/web/ui/react-app/src/pages/graph/GraphControls.tsx index d8544bc049..c465e4395b 100644 --- a/web/ui/react-app/src/pages/graph/GraphControls.tsx +++ b/web/ui/react-app/src/pages/graph/GraphControls.tsx @@ -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 { @@ -147,6 +148,18 @@ class GraphControls extends Component { + + + {this.props.showExemplars ? ( + + ) : ( + + )} + ); } diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts index 8a44d0d249..989643cfbd 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts @@ -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', () => {
foo: 1
bar: 2
-
- `); +
`); }); it('should return proper tooltip html from options with local time', () => { moment.tz.setDefault('America/New_York'); @@ -166,10 +165,29 @@ describe('GraphHelpers', () => { value: 1572128592
+
+
foo: 1
bar: 2
+
`); + }); + 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(` +
1970-01-19 04:42:08 +00:00
+
+ + value: 1572128592 +
foo: 1
bar: 2
- `); + + Series labels: +
+
foo: 2
bar: 3
+
`); }); 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, }, diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.ts index 5360167f2a..4d09010953 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.ts @@ -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('')}
- `; + ${ + 'seriesLabels' in both + ? ` + Series labels: +
+ ${Object.keys(both.seriesLabels) + .map(k => + k !== '__name__' + ? `
${k}: ${escapeHTML(both.seriesLabels[k])}
` + : '' + ) + .join('')} +
+ ` + : '' + } + `.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]; diff --git a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx index 2350cb117e..a29269cf51 100644 --- a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx +++ b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx @@ -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 = ({ data, stacked, useLocalTime, lastQueryParams }) => { +export const GraphTabContent: FC = ({ + data, + exemplars, + stacked, + useLocalTime, + lastQueryParams, + showExemplars, + id, +}) => { if (!isPresent(data)) { return No data queried yet; } @@ -23,5 +34,15 @@ export const GraphTabContent: FC = ({ data, stacked, useLo Query result is of wrong type '{data.resultType}', should be 'matrix' (range vector). ); } - return ; + return ( + + ); }; diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index d498d2109c..3600edbdf6 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -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 { @@ -70,6 +74,7 @@ class Panel extends Component { this.state = { data: null, + exemplars: [], lastQueryParams: null, loading: false, warnings: null, @@ -80,12 +85,13 @@ class Panel extends Component { } 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 { this.executeQuery(); } - executeQuery = (): void => { + executeQuery = async (): Promise => { const { exprInputValue: expr } = this.state; const queryStart = Date.now(); this.props.onExecuteQuery(expr); @@ -138,55 +144,70 @@ class Panel extends Component { 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 { 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 { 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} /> )} diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 8e9ca48e07..43ff6f1d68 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -90,6 +90,7 @@ export const PanelListContent: FC = ({ 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))) diff --git a/web/ui/react-app/src/types/types.ts b/web/ui/react-app/src/types/types.ts index 1eee32d3db..8f42f0836d 100644 --- a/web/ui/react-app/src/types/types.ts +++ b/web/ui/react-app/src/types/types.ts @@ -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; diff --git a/web/ui/react-app/src/utils/index.ts b/web/ui/react-app/src/utils/index.ts index 802eccbfd1..a452cd2667 100644 --- a/web/ui/react-app/src/utils/index.ts +++ b/web/ui/react-app/src/utils/index.ts @@ -201,6 +201,9 @@ export const parseOption = (param: string): Partial => { 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 = ( o: T, diff --git a/web/ui/react-app/src/utils/utils.test.ts b/web/ui/react-app/src/utils/utils.test.ts index 44fbfc382c..c7fcc635c0 100644 --- a/web/ui/react-app/src/utils/utils.test.ts +++ b/web/ui/react-app/src/utils/utils.test.ts @@ -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'); }); });