mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
Alerting: Improve threshold displays (#60046)
This commit is contained in:
parent
58716de073
commit
e9fe9baf66
@ -10,6 +10,7 @@ import { QueryRows } from './QueryRows';
|
|||||||
interface Props {
|
interface Props {
|
||||||
panelData: Record<string, PanelData>;
|
panelData: Record<string, PanelData>;
|
||||||
queries: AlertQuery[];
|
queries: AlertQuery[];
|
||||||
|
expressions: AlertQuery[];
|
||||||
onRunQueries: () => void;
|
onRunQueries: () => void;
|
||||||
onChangeQueries: (queries: AlertQuery[]) => void;
|
onChangeQueries: (queries: AlertQuery[]) => void;
|
||||||
onDuplicateQuery: (query: AlertQuery) => void;
|
onDuplicateQuery: (query: AlertQuery) => void;
|
||||||
@ -19,6 +20,7 @@ interface Props {
|
|||||||
|
|
||||||
export const QueryEditor: FC<Props> = ({
|
export const QueryEditor: FC<Props> = ({
|
||||||
queries,
|
queries,
|
||||||
|
expressions,
|
||||||
panelData,
|
panelData,
|
||||||
onRunQueries,
|
onRunQueries,
|
||||||
onChangeQueries,
|
onChangeQueries,
|
||||||
@ -33,6 +35,7 @@ export const QueryEditor: FC<Props> = ({
|
|||||||
<QueryRows
|
<QueryRows
|
||||||
data={panelData}
|
data={panelData}
|
||||||
queries={queries}
|
queries={queries}
|
||||||
|
expressions={expressions}
|
||||||
onRunQueries={onRunQueries}
|
onRunQueries={onRunQueries}
|
||||||
onQueriesChange={onChangeQueries}
|
onQueriesChange={onChangeQueries}
|
||||||
onDuplicateQuery={onDuplicateQuery}
|
onDuplicateQuery={onDuplicateQuery}
|
||||||
|
@ -2,28 +2,20 @@ import { omit } from 'lodash';
|
|||||||
import React, { PureComponent, useState } from 'react';
|
import React, { PureComponent, useState } from 'react';
|
||||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||||
|
|
||||||
import {
|
import { DataQuery, DataSourceInstanceSettings, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||||
DataQuery,
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
DataSourceInstanceSettings,
|
|
||||||
LoadingState,
|
|
||||||
PanelData,
|
|
||||||
RelativeTimeRange,
|
|
||||||
ThresholdsConfig,
|
|
||||||
ThresholdsMode,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { config, getDataSourceSrv } from '@grafana/runtime';
|
|
||||||
import { Button, Card, Icon } from '@grafana/ui';
|
import { Button, Card, Icon } from '@grafana/ui';
|
||||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
|
||||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
|
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
|
||||||
import { errorFromSeries } from './util';
|
import { errorFromSeries, getThresholdsForQueries } from './util';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// The query configuration
|
// The query configuration
|
||||||
queries: AlertQuery[];
|
queries: AlertQuery[];
|
||||||
|
expressions: AlertQuery[];
|
||||||
data: Record<string, PanelData>;
|
data: Record<string, PanelData>;
|
||||||
onRunQueries: () => void;
|
onRunQueries: () => void;
|
||||||
|
|
||||||
@ -118,53 +110,9 @@ export class QueryRows extends PureComponent<Props> {
|
|||||||
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
||||||
};
|
};
|
||||||
|
|
||||||
getThresholdsForQueries = (queries: AlertQuery[]): Record<string, ThresholdsConfig> => {
|
|
||||||
const record: Record<string, ThresholdsConfig> = {};
|
|
||||||
|
|
||||||
for (const query of queries) {
|
|
||||||
if (!isExpressionQuery(query.model)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(query.model.conditions)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
query.model.conditions.forEach((condition, index) => {
|
|
||||||
if (index > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const threshold = condition.evaluator.params[0];
|
|
||||||
const refId = condition.query.params[0];
|
|
||||||
|
|
||||||
if (condition.evaluator.type === 'outside_range' || condition.evaluator.type === 'within_range') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!record[refId]) {
|
|
||||||
record[refId] = {
|
|
||||||
mode: ThresholdsMode.Absolute,
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
value: -Infinity,
|
|
||||||
color: config.theme2.colors.success.main,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
record[refId].steps.push({
|
|
||||||
value: threshold,
|
|
||||||
color: config.theme2.colors.error.main,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return record;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { queries } = this.props;
|
const { queries, expressions } = this.props;
|
||||||
const thresholdByRefId = this.getThresholdsForQueries(queries);
|
const thresholdByRefId = getThresholdsForQueries([...queries, ...expressions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||||
@ -215,7 +163,8 @@ export class QueryRows extends PureComponent<Props> {
|
|||||||
onChangeDataSource={this.onChangeDataSource}
|
onChangeDataSource={this.onChangeDataSource}
|
||||||
onDuplicateQuery={this.props.onDuplicateQuery}
|
onDuplicateQuery={this.props.onDuplicateQuery}
|
||||||
onChangeTimeRange={this.onChangeTimeRange}
|
onChangeTimeRange={this.onChangeTimeRange}
|
||||||
thresholds={thresholdByRefId[query.refId]}
|
thresholds={thresholdByRefId[query.refId]?.config}
|
||||||
|
thresholdsType={thresholdByRefId[query.refId]?.mode}
|
||||||
onRunQueries={this.props.onRunQueries}
|
onRunQueries={this.props.onRunQueries}
|
||||||
condition={this.props.condition}
|
condition={this.props.condition}
|
||||||
onSetCondition={this.props.onSetCondition}
|
onSetCondition={this.props.onSetCondition}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { cloneDeep, noop } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
ThresholdsConfig,
|
ThresholdsConfig,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon } from '@grafana/ui';
|
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon, GraphTresholdsStyleMode } from '@grafana/ui';
|
||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
@ -39,6 +39,7 @@ interface Props {
|
|||||||
onRunQueries: () => void;
|
onRunQueries: () => void;
|
||||||
index: number;
|
index: number;
|
||||||
thresholds: ThresholdsConfig;
|
thresholds: ThresholdsConfig;
|
||||||
|
thresholdsType?: GraphTresholdsStyleMode;
|
||||||
onChangeThreshold?: (thresholds: ThresholdsConfig, index: number) => void;
|
onChangeThreshold?: (thresholds: ThresholdsConfig, index: number) => void;
|
||||||
condition: string | null;
|
condition: string | null;
|
||||||
onSetCondition: (refId: string) => void;
|
onSetCondition: (refId: string) => void;
|
||||||
@ -58,6 +59,7 @@ export const QueryWrapper: FC<Props> = ({
|
|||||||
query,
|
query,
|
||||||
queries,
|
queries,
|
||||||
thresholds,
|
thresholds,
|
||||||
|
thresholdsType,
|
||||||
onChangeThreshold,
|
onChangeThreshold,
|
||||||
condition,
|
condition,
|
||||||
onSetCondition,
|
onSetCondition,
|
||||||
@ -141,7 +143,8 @@ export const QueryWrapper: FC<Props> = ({
|
|||||||
changePanel={changePluginId}
|
changePanel={changePluginId}
|
||||||
currentPanel={pluginId}
|
currentPanel={pluginId}
|
||||||
thresholds={thresholds}
|
thresholds={thresholds}
|
||||||
onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : noop}
|
thresholdsType={thresholdsType}
|
||||||
|
onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : undefined}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
@ -17,12 +17,19 @@ interface Props {
|
|||||||
currentPanel: SupportedPanelPlugins;
|
currentPanel: SupportedPanelPlugins;
|
||||||
changePanel: (panel: SupportedPanelPlugins) => void;
|
changePanel: (panel: SupportedPanelPlugins) => void;
|
||||||
thresholds?: ThresholdsConfig;
|
thresholds?: ThresholdsConfig;
|
||||||
|
thresholdsType?: GraphTresholdsStyleMode;
|
||||||
onThresholdsChange?: (thresholds: ThresholdsConfig) => void;
|
onThresholdsChange?: (thresholds: ThresholdsConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelFieldConfig = FieldConfigSource<GraphFieldConfig>;
|
type PanelFieldConfig = FieldConfigSource<GraphFieldConfig>;
|
||||||
|
|
||||||
export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThresholdsChange, thresholds }) => {
|
export const VizWrapper: FC<Props> = ({
|
||||||
|
data,
|
||||||
|
currentPanel,
|
||||||
|
changePanel,
|
||||||
|
thresholds,
|
||||||
|
thresholdsType = GraphTresholdsStyleMode.Line,
|
||||||
|
}) => {
|
||||||
const [options, setOptions] = useState<PanelOptions>({
|
const [options, setOptions] = useState<PanelOptions>({
|
||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
@ -42,21 +49,20 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThres
|
|||||||
custom: {
|
custom: {
|
||||||
...fieldConfig.defaults.custom,
|
...fieldConfig.defaults.custom,
|
||||||
thresholdsStyle: {
|
thresholdsStyle: {
|
||||||
mode: GraphTresholdsStyleMode.Line,
|
mode: thresholdsType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [thresholds, setFieldConfig, data]);
|
}, [thresholds, setFieldConfig, data, thresholdsType]);
|
||||||
|
|
||||||
const context: PanelContext = useMemo(
|
const context: PanelContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
eventBus: appEvents,
|
eventBus: appEvents,
|
||||||
canEditThresholds: false,
|
canEditThresholds: false,
|
||||||
showThresholds: true,
|
showThresholds: true,
|
||||||
onThresholdsChange: onThresholdsChange,
|
|
||||||
}),
|
}),
|
||||||
[onThresholdsChange]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!options || !data) {
|
if (!options || !data) {
|
||||||
|
@ -0,0 +1,143 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for classic_condition 1`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for lt and gt 1`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for lt and gt 2`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for outside_range 1`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line+area",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for threshold condition 1`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getThresholdsForQueries should work for within_range 1`] = `
|
||||||
|
{
|
||||||
|
"A": {
|
||||||
|
"config": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": -Infinity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#D10E5C",
|
||||||
|
"value": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mode": "line+area",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
@ -0,0 +1,143 @@
|
|||||||
|
import { Graph } from 'app/core/utils/dag';
|
||||||
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
_getOriginsOfRefId,
|
||||||
|
parseRefsFromMathExpression,
|
||||||
|
_createDagFromQueries,
|
||||||
|
fingerprintGraph,
|
||||||
|
fingerPrintQueries,
|
||||||
|
} from './dag';
|
||||||
|
|
||||||
|
describe('working with dag', () => {
|
||||||
|
test('with data query and expressions', () => {
|
||||||
|
const queries = [
|
||||||
|
{
|
||||||
|
refId: 'A',
|
||||||
|
model: {
|
||||||
|
refId: 'A',
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refId: 'B',
|
||||||
|
model: {
|
||||||
|
refId: 'B',
|
||||||
|
expression: 'A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refId: 'C',
|
||||||
|
model: {
|
||||||
|
refId: 'C',
|
||||||
|
expression: '$B > 0',
|
||||||
|
type: 'math',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as AlertQuery[];
|
||||||
|
|
||||||
|
const dag = _createDagFromQueries(queries);
|
||||||
|
|
||||||
|
expect(Object.keys(dag.nodes)).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
dag.getNode('A');
|
||||||
|
dag.getNode('B');
|
||||||
|
dag.getNode('C');
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(dag.getNode('A').inputEdges).toHaveLength(0);
|
||||||
|
expect(dag.getNode('A').outputEdges).toHaveLength(1);
|
||||||
|
expect(dag.getNode('A').outputEdges[0].outputNode).toHaveProperty('name', 'B');
|
||||||
|
|
||||||
|
expect(dag.getNode('B').inputEdges).toHaveLength(1);
|
||||||
|
expect(dag.getNode('B').outputEdges).toHaveLength(1);
|
||||||
|
expect(dag.getNode('B').inputEdges[0].inputNode).toHaveProperty('name', 'A');
|
||||||
|
expect(dag.getNode('B').outputEdges[0].outputNode).toHaveProperty('name', 'C');
|
||||||
|
|
||||||
|
expect(dag.getNode('C').inputEdges).toHaveLength(1);
|
||||||
|
expect(dag.getNode('C').outputEdges).toHaveLength(0);
|
||||||
|
expect(dag.getNode('C').inputEdges[0].inputNode).toHaveProperty('name', 'B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOriginsOfRefId', () => {
|
||||||
|
test('with multiple sources', () => {
|
||||||
|
const graph = new Graph();
|
||||||
|
graph.createNodes(['A', 'B', 'C', 'D']);
|
||||||
|
graph.link('A', 'B');
|
||||||
|
graph.link('B', 'C');
|
||||||
|
graph.link('D', 'B');
|
||||||
|
|
||||||
|
expect(_getOriginsOfRefId('C', graph)).toEqual(['A', 'D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with single source', () => {
|
||||||
|
const graph = new Graph();
|
||||||
|
graph.createNodes(['A', 'B', 'C', 'D']);
|
||||||
|
graph.link('A', 'B');
|
||||||
|
graph.link('B', 'C');
|
||||||
|
graph.link('B', 'D');
|
||||||
|
|
||||||
|
expect(_getOriginsOfRefId('C', graph)).toEqual(['A']);
|
||||||
|
expect(_getOriginsOfRefId('D', graph)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRefsFromMathExpression', () => {
|
||||||
|
const cases: Array<[string, string[]]> = [
|
||||||
|
['$A', ['A']],
|
||||||
|
['$A > $B', ['A', 'B']],
|
||||||
|
['$FOO123 > $BAR123', ['FOO123', 'BAR123']],
|
||||||
|
['${FOO BAR} > 0', ['FOO BAR']],
|
||||||
|
['$A\n || \n $B', ['A', 'B']],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(cases)('testing "%s"', (input, output) => {
|
||||||
|
expect(parseRefsFromMathExpression(input)).toEqual(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fingerprints', () => {
|
||||||
|
test('DAG fingerprint', () => {
|
||||||
|
const graph = new Graph();
|
||||||
|
graph.createNodes(['A', 'B', 'C', 'D']);
|
||||||
|
graph.link('A', 'B');
|
||||||
|
graph.link('B', 'C');
|
||||||
|
graph.link('D', 'B');
|
||||||
|
|
||||||
|
expect(fingerprintGraph(graph)).toMatchInlineSnapshot(`"A:B: B:C:A, D C::B D:B:"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Queries fingerprint', () => {
|
||||||
|
const queries = [
|
||||||
|
{
|
||||||
|
refId: 'A',
|
||||||
|
queryType: 'query',
|
||||||
|
model: {
|
||||||
|
refId: 'A',
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refId: 'B',
|
||||||
|
queryType: 'query',
|
||||||
|
model: {
|
||||||
|
refId: 'B',
|
||||||
|
expression: 'A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refId: 'C',
|
||||||
|
queryType: 'query',
|
||||||
|
model: {
|
||||||
|
refId: 'C',
|
||||||
|
expression: '$B > 0',
|
||||||
|
type: 'math',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as AlertQuery[];
|
||||||
|
|
||||||
|
expect(fingerPrintQueries(queries)).toMatchInlineSnapshot(`"Aquery,BAquery,C$B > 0math"`);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,108 @@
|
|||||||
|
import { compact, memoize, uniq } from 'lodash';
|
||||||
|
import memoizeOne from 'memoize-one';
|
||||||
|
|
||||||
|
import { Edge, Graph, Node } from 'app/core/utils/dag';
|
||||||
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
// memoized version of _createDagFromQueries to prevent recreating the DAG if no sources or targets are modified
|
||||||
|
export const createDagFromQueries = memoizeOne(
|
||||||
|
_createDagFromQueries,
|
||||||
|
(previous: Parameters<typeof _createDagFromQueries>, next: Parameters<typeof _createDagFromQueries>) => {
|
||||||
|
return fingerPrintQueries(previous[0]) === fingerPrintQueries(next[0]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the array of alert queries (this means data queries and expressions)
|
||||||
|
* in to a DAG, a directed acyclical graph
|
||||||
|
*/
|
||||||
|
export function _createDagFromQueries(queries: AlertQuery[]): Graph {
|
||||||
|
const graph = new Graph();
|
||||||
|
|
||||||
|
const nodes = queries.map((query) => query.refId);
|
||||||
|
graph.createNodes(nodes);
|
||||||
|
|
||||||
|
queries.forEach((query) => {
|
||||||
|
const source = query.refId;
|
||||||
|
const isMathExpression = isExpressionQuery(query.model) && query.model.type === 'math';
|
||||||
|
|
||||||
|
// some expressions have multiple targets (like the math expression)
|
||||||
|
const targets = isMathExpression
|
||||||
|
? parseRefsFromMathExpression(query.model.expression ?? '')
|
||||||
|
: [query.model.expression];
|
||||||
|
|
||||||
|
targets.forEach((target) => {
|
||||||
|
const isSelf = source === target;
|
||||||
|
|
||||||
|
if (source && target && !isSelf) {
|
||||||
|
graph.link(target, source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse an expression like "$A > $B" or "${FOO BAR} > 0" to an array of refIds
|
||||||
|
*/
|
||||||
|
export function parseRefsFromMathExpression(input: string): string[] {
|
||||||
|
// we'll use two regular expressions, one for "${var}" and one for "$var"
|
||||||
|
const r1 = new RegExp(/\$\{(?<var>[a-zA-Z0-9_ ]+?)\}/gm);
|
||||||
|
const r2 = new RegExp(/\$(?<var>[a-zA-Z0-9_]+)/gm);
|
||||||
|
|
||||||
|
const m1 = Array.from(input.matchAll(r1)).map((m) => m.groups?.var);
|
||||||
|
const m2 = Array.from(input.matchAll(r2)).map((m) => m.groups?.var);
|
||||||
|
|
||||||
|
return compact(uniq([...m1, ...m2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOriginOfRefId = memoize(_getOriginsOfRefId, (refId, graph) => refId + fingerprintGraph(graph));
|
||||||
|
|
||||||
|
export function _getOriginsOfRefId(refId: string, graph: Graph): string[] {
|
||||||
|
const node = graph.getNode(refId);
|
||||||
|
|
||||||
|
let origins: Node[] = [];
|
||||||
|
|
||||||
|
// recurse through "node > inputEdges > inputNode"
|
||||||
|
function findChildNode(node: Node) {
|
||||||
|
const inputEdges = node.inputEdges;
|
||||||
|
|
||||||
|
if (inputEdges.length > 0) {
|
||||||
|
inputEdges.forEach((edge) => {
|
||||||
|
if (edge.inputNode) {
|
||||||
|
findChildNode(edge.inputNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
origins?.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findChildNode(node);
|
||||||
|
|
||||||
|
return origins.map((origin) => origin.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a unique fingerprint of the DAG
|
||||||
|
export function fingerprintGraph(graph: Graph) {
|
||||||
|
return Object.keys(graph.nodes)
|
||||||
|
.map((name) => {
|
||||||
|
const n = graph.nodes[name];
|
||||||
|
let outputEdges = n.outputEdges.map((e: Edge) => e.outputNode?.name).join(', ');
|
||||||
|
let inputEdges = n.inputEdges.map((e: Edge) => e.inputNode?.name).join(', ');
|
||||||
|
return `${n.name}:${outputEdges}:${inputEdges}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a unique fingerprint of the array of queries
|
||||||
|
export function fingerPrintQueries(queries: AlertQuery[]) {
|
||||||
|
return queries
|
||||||
|
.map((query) => {
|
||||||
|
const type = isExpressionQuery(query.model) ? query.model.type : query.queryType;
|
||||||
|
return query.refId + (query.model.expression ?? '') + type;
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
}
|
@ -97,6 +97,11 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule }) => {
|
|||||||
return queries.filter((query) => !isExpressionQuery(query.model));
|
return queries.filter((query) => !isExpressionQuery(query.model));
|
||||||
}, [queries]);
|
}, [queries]);
|
||||||
|
|
||||||
|
// expression queries only
|
||||||
|
const expressionQueries = useMemo(() => {
|
||||||
|
return queries.filter((query) => isExpressionQuery(query.model));
|
||||||
|
}, [queries]);
|
||||||
|
|
||||||
const emptyQueries = queries.length === 0;
|
const emptyQueries = queries.length === 0;
|
||||||
|
|
||||||
const onUpdateRefId = useCallback(
|
const onUpdateRefId = useCallback(
|
||||||
@ -172,6 +177,7 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule }) => {
|
|||||||
{/* Data Queries */}
|
{/* Data Queries */}
|
||||||
<QueryEditor
|
<QueryEditor
|
||||||
queries={dataQueries}
|
queries={dataQueries}
|
||||||
|
expressions={expressionQueries}
|
||||||
onRunQueries={runQueries}
|
onRunQueries={runQueries}
|
||||||
onChangeQueries={onChangeQueries}
|
onChangeQueries={onChangeQueries}
|
||||||
onDuplicateQuery={onDuplicateQuery}
|
onDuplicateQuery={onDuplicateQuery}
|
||||||
|
@ -2,7 +2,12 @@ import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWi
|
|||||||
import { ClassicCondition, ExpressionQuery } from 'app/features/expressions/types';
|
import { ClassicCondition, ExpressionQuery } from 'app/features/expressions/types';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
import { checkForPathSeparator, queriesWithUpdatedReferences, updateMathExpressionRefs } from './util';
|
import {
|
||||||
|
checkForPathSeparator,
|
||||||
|
getThresholdsForQueries,
|
||||||
|
queriesWithUpdatedReferences,
|
||||||
|
updateMathExpressionRefs,
|
||||||
|
} from './util';
|
||||||
|
|
||||||
describe('rule-editor', () => {
|
describe('rule-editor', () => {
|
||||||
const dataSource: AlertQuery = {
|
const dataSource: AlertQuery = {
|
||||||
@ -236,3 +241,126 @@ describe('checkForPathSeparator', () => {
|
|||||||
expect(checkForPathSeparator('foo bar')).toBe(true);
|
expect(checkForPathSeparator('foo bar')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getThresholdsForQueries', () => {
|
||||||
|
it('should work for threshold condition', () => {
|
||||||
|
const queries = createThresholdExample('gt');
|
||||||
|
expect(getThresholdsForQueries(queries)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work for classic_condition', () => {
|
||||||
|
const [dataQuery] = createThresholdExample('gt');
|
||||||
|
|
||||||
|
const classicCondition = {
|
||||||
|
refId: 'B',
|
||||||
|
datasourceUid: '-100',
|
||||||
|
queryType: '',
|
||||||
|
model: {
|
||||||
|
refId: 'B',
|
||||||
|
type: 'classic_conditions',
|
||||||
|
datasource: ExpressionDatasourceRef,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
evaluator: {
|
||||||
|
params: [0],
|
||||||
|
type: 'gt',
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: 'and',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
params: ['A'],
|
||||||
|
},
|
||||||
|
reducer: {
|
||||||
|
params: [],
|
||||||
|
type: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const thresholdsClassic = getThresholdsForQueries([dataQuery, classicCondition]);
|
||||||
|
expect(thresholdsClassic).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work for within_range', () => {
|
||||||
|
const queries = createThresholdExample('within_range');
|
||||||
|
const thresholds = getThresholdsForQueries(queries);
|
||||||
|
expect(thresholds).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work for lt and gt', () => {
|
||||||
|
expect(getThresholdsForQueries(createThresholdExample('gt'))).toMatchSnapshot();
|
||||||
|
expect(getThresholdsForQueries(createThresholdExample('lt'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work for outside_range', () => {
|
||||||
|
const queries = createThresholdExample('outside_range');
|
||||||
|
const thresholds = getThresholdsForQueries(queries);
|
||||||
|
expect(thresholds).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createThresholdExample(thresholdType: string): AlertQuery[] {
|
||||||
|
const dataQuery: AlertQuery = {
|
||||||
|
refId: 'A',
|
||||||
|
datasourceUid: 'abc123',
|
||||||
|
queryType: '',
|
||||||
|
relativeTimeRange: {
|
||||||
|
from: 600,
|
||||||
|
to: 0,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
refId: 'A',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reduceExpression = {
|
||||||
|
refId: 'B',
|
||||||
|
datasourceUid: '-100',
|
||||||
|
queryType: '',
|
||||||
|
model: {
|
||||||
|
refId: 'B',
|
||||||
|
type: 'reduce',
|
||||||
|
datasource: ExpressionDatasourceRef,
|
||||||
|
conditions: [],
|
||||||
|
reducer: 'mean',
|
||||||
|
expression: 'A',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const thresholdExpression = {
|
||||||
|
refId: 'C',
|
||||||
|
datasourceUid: '-100',
|
||||||
|
queryType: '',
|
||||||
|
model: {
|
||||||
|
refId: 'C',
|
||||||
|
type: 'threshold',
|
||||||
|
datasource: ExpressionDatasourceRef,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
evaluator: {
|
||||||
|
params: [0, 10],
|
||||||
|
type: thresholdType ?? 'gt',
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: 'and',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
params: ['B'],
|
||||||
|
},
|
||||||
|
reducer: {
|
||||||
|
params: [],
|
||||||
|
type: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expression: 'B',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [dataQuery, reduceExpression, thresholdExpression];
|
||||||
|
}
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { ValidateResult } from 'react-hook-form';
|
import { ValidateResult } from 'react-hook-form';
|
||||||
|
|
||||||
import { DataFrame } from '@grafana/data';
|
import { DataFrame, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
|
||||||
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
|
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
|
||||||
|
import { GraphTresholdsStyleMode } from '@grafana/schema';
|
||||||
|
import { config } from 'app/core/config';
|
||||||
|
import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
|
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { createDagFromQueries, getOriginOfRefId } from './dag';
|
||||||
|
|
||||||
export function queriesWithUpdatedReferences(
|
export function queriesWithUpdatedReferences(
|
||||||
queries: AlertQuery[],
|
queries: AlertQuery[],
|
||||||
previousRefId: string,
|
previousRefId: string,
|
||||||
@ -108,3 +114,161 @@ export function warningFromSeries(series: DataFrame[]): Error | undefined {
|
|||||||
|
|
||||||
return warning ? new Error(warning) : undefined;
|
return warning ? new Error(warning) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThresholdDefinitions = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
config: ThresholdsConfig;
|
||||||
|
mode: GraphTresholdsStyleMode;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function will retrieve threshold definitions for the given array of data and expression queries.
|
||||||
|
*/
|
||||||
|
export function getThresholdsForQueries(queries: AlertQuery[]) {
|
||||||
|
const thresholds: ThresholdDefinitions = {};
|
||||||
|
const SUPPORTED_EXPRESSION_TYPES = [ExpressionQueryType.threshold, ExpressionQueryType.classic];
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
if (!isExpressionQuery(query.model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// currently only supporting "threshold" & "classic_condition" expressions
|
||||||
|
if (!SUPPORTED_EXPRESSION_TYPES.includes(query.model.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(query.model.conditions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if any of the conditions are a "range" we switch to an "area" threshold view and ignore single threshold values
|
||||||
|
// the time series panel does not support both.
|
||||||
|
const hasRangeThreshold = query.model.conditions.some(isRangeCondition);
|
||||||
|
|
||||||
|
query.model.conditions.forEach((condition, index) => {
|
||||||
|
const threshold = condition.evaluator.params;
|
||||||
|
|
||||||
|
// "classic_conditions" use `condition.query.params[]` and "threshold" uses `query.model.expression`
|
||||||
|
const refId = condition.query.params[0] ?? query.model.expression;
|
||||||
|
const isRangeThreshold = isRangeCondition(condition);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// create a DAG so we can find the origin of the current expression
|
||||||
|
const graph = createDagFromQueries(queries);
|
||||||
|
|
||||||
|
const originRefIDs = getOriginOfRefId(refId, graph);
|
||||||
|
const originQueries = queries.filter((query) => originRefIDs.includes(query.refId));
|
||||||
|
|
||||||
|
originQueries.forEach((originQuery) => {
|
||||||
|
const originRefID = originQuery.refId;
|
||||||
|
|
||||||
|
// check if the origin is a data query
|
||||||
|
const originIsDataQuery = !isExpressionQuery(originQuery?.model);
|
||||||
|
|
||||||
|
// if yes, add threshold config to the refId of the data Query
|
||||||
|
const hasValidOrigin = Boolean(originIsDataQuery && originRefID);
|
||||||
|
|
||||||
|
// create the initial data structure for this origin refId
|
||||||
|
if (originRefID && !thresholds[originRefID]) {
|
||||||
|
thresholds[originRefID] = {
|
||||||
|
config: {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
mode: GraphTresholdsStyleMode.Line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originRefID && hasValidOrigin && !isRangeThreshold && !hasRangeThreshold) {
|
||||||
|
appendSingleThreshold(originRefID, threshold[0]);
|
||||||
|
} else if (originRefID && hasValidOrigin && isRangeThreshold) {
|
||||||
|
appendRangeThreshold(originRefID, threshold, condition.evaluator.type);
|
||||||
|
thresholds[originRefID].mode = GraphTresholdsStyleMode.LineAndArea;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse thresholds', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSingleThreshold(refId: string, value: number): void {
|
||||||
|
thresholds[refId].config.steps.push(
|
||||||
|
...[
|
||||||
|
{
|
||||||
|
value: -Infinity,
|
||||||
|
color: 'transparent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: value,
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRangeThreshold(refId: string, values: number[], type: EvalFunction): void {
|
||||||
|
if (type === EvalFunction.IsWithinRange) {
|
||||||
|
thresholds[refId].config.steps.push(
|
||||||
|
...[
|
||||||
|
{
|
||||||
|
value: -Infinity,
|
||||||
|
color: 'transparent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: values[0],
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: values[1],
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: values[1],
|
||||||
|
color: 'transparent',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === EvalFunction.IsOutsideRange) {
|
||||||
|
thresholds[refId].config.steps.push(
|
||||||
|
...[
|
||||||
|
{
|
||||||
|
value: -Infinity,
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
// we have to duplicate this value, or the graph will not display the handle in the right color
|
||||||
|
{
|
||||||
|
value: values[0],
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: values[0],
|
||||||
|
color: 'transparent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: values[1],
|
||||||
|
color: config.theme2.colors.error.main,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// now also sort the threshold values, if we don't then they will look weird in the time series panel
|
||||||
|
// TODO this doesn't work for negative values for now, those need to be sorted inverse
|
||||||
|
thresholds[refId].config.steps.sort((a, b) => a.value - b.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return thresholds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRangeCondition(condition: ClassicCondition) {
|
||||||
|
return (
|
||||||
|
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user