`);
+ });
+ 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(`
+
+
+
value: 1572128592
+
- `);
+
+
Series labels:
+
`);
});
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');
});
});