mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 03:34:15 -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 {
|
||||
panelData: Record<string, PanelData>;
|
||||
queries: AlertQuery[];
|
||||
expressions: AlertQuery[];
|
||||
onRunQueries: () => void;
|
||||
onChangeQueries: (queries: AlertQuery[]) => void;
|
||||
onDuplicateQuery: (query: AlertQuery) => void;
|
||||
@ -19,6 +20,7 @@ interface Props {
|
||||
|
||||
export const QueryEditor: FC<Props> = ({
|
||||
queries,
|
||||
expressions,
|
||||
panelData,
|
||||
onRunQueries,
|
||||
onChangeQueries,
|
||||
@ -33,6 +35,7 @@ export const QueryEditor: FC<Props> = ({
|
||||
<QueryRows
|
||||
data={panelData}
|
||||
queries={queries}
|
||||
expressions={expressions}
|
||||
onRunQueries={onRunQueries}
|
||||
onQueriesChange={onChangeQueries}
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
|
@ -2,28 +2,20 @@ import { omit } from 'lodash';
|
||||
import React, { PureComponent, useState } from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import {
|
||||
DataQuery,
|
||||
DataSourceInstanceSettings,
|
||||
LoadingState,
|
||||
PanelData,
|
||||
RelativeTimeRange,
|
||||
ThresholdsConfig,
|
||||
ThresholdsMode,
|
||||
} from '@grafana/data';
|
||||
import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataQuery, DataSourceInstanceSettings, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Button, Card, Icon } from '@grafana/ui';
|
||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
|
||||
import { errorFromSeries } from './util';
|
||||
import { errorFromSeries, getThresholdsForQueries } from './util';
|
||||
|
||||
interface Props {
|
||||
// The query configuration
|
||||
queries: AlertQuery[];
|
||||
expressions: AlertQuery[];
|
||||
data: Record<string, PanelData>;
|
||||
onRunQueries: () => void;
|
||||
|
||||
@ -118,53 +110,9 @@ export class QueryRows extends PureComponent<Props> {
|
||||
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() {
|
||||
const { queries } = this.props;
|
||||
const thresholdByRefId = this.getThresholdsForQueries(queries);
|
||||
const { queries, expressions } = this.props;
|
||||
const thresholdByRefId = getThresholdsForQueries([...queries, ...expressions]);
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
@ -215,7 +163,8 @@ export class QueryRows extends PureComponent<Props> {
|
||||
onChangeDataSource={this.onChangeDataSource}
|
||||
onDuplicateQuery={this.props.onDuplicateQuery}
|
||||
onChangeTimeRange={this.onChangeTimeRange}
|
||||
thresholds={thresholdByRefId[query.refId]}
|
||||
thresholds={thresholdByRefId[query.refId]?.config}
|
||||
thresholdsType={thresholdByRefId[query.refId]?.mode}
|
||||
onRunQueries={this.props.onRunQueries}
|
||||
condition={this.props.condition}
|
||||
onSetCondition={this.props.onSetCondition}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { cloneDeep, noop } from 'lodash';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
import {
|
||||
@ -14,7 +14,7 @@ import {
|
||||
ThresholdsConfig,
|
||||
} from '@grafana/data';
|
||||
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 { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
@ -39,6 +39,7 @@ interface Props {
|
||||
onRunQueries: () => void;
|
||||
index: number;
|
||||
thresholds: ThresholdsConfig;
|
||||
thresholdsType?: GraphTresholdsStyleMode;
|
||||
onChangeThreshold?: (thresholds: ThresholdsConfig, index: number) => void;
|
||||
condition: string | null;
|
||||
onSetCondition: (refId: string) => void;
|
||||
@ -58,6 +59,7 @@ export const QueryWrapper: FC<Props> = ({
|
||||
query,
|
||||
queries,
|
||||
thresholds,
|
||||
thresholdsType,
|
||||
onChangeThreshold,
|
||||
condition,
|
||||
onSetCondition,
|
||||
@ -141,7 +143,8 @@ export const QueryWrapper: FC<Props> = ({
|
||||
changePanel={changePluginId}
|
||||
currentPanel={pluginId}
|
||||
thresholds={thresholds}
|
||||
onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : noop}
|
||||
thresholdsType={thresholdsType}
|
||||
onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : undefined}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
@ -17,12 +17,19 @@ interface Props {
|
||||
currentPanel: SupportedPanelPlugins;
|
||||
changePanel: (panel: SupportedPanelPlugins) => void;
|
||||
thresholds?: ThresholdsConfig;
|
||||
thresholdsType?: GraphTresholdsStyleMode;
|
||||
onThresholdsChange?: (thresholds: ThresholdsConfig) => void;
|
||||
}
|
||||
|
||||
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>({
|
||||
frameIndex: 0,
|
||||
showHeader: true,
|
||||
@ -42,21 +49,20 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThres
|
||||
custom: {
|
||||
...fieldConfig.defaults.custom,
|
||||
thresholdsStyle: {
|
||||
mode: GraphTresholdsStyleMode.Line,
|
||||
mode: thresholdsType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, [thresholds, setFieldConfig, data]);
|
||||
}, [thresholds, setFieldConfig, data, thresholdsType]);
|
||||
|
||||
const context: PanelContext = useMemo(
|
||||
() => ({
|
||||
eventBus: appEvents,
|
||||
canEditThresholds: false,
|
||||
showThresholds: true,
|
||||
onThresholdsChange: onThresholdsChange,
|
||||
}),
|
||||
[onThresholdsChange]
|
||||
[]
|
||||
);
|
||||
|
||||
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));
|
||||
}, [queries]);
|
||||
|
||||
// expression queries only
|
||||
const expressionQueries = useMemo(() => {
|
||||
return queries.filter((query) => isExpressionQuery(query.model));
|
||||
}, [queries]);
|
||||
|
||||
const emptyQueries = queries.length === 0;
|
||||
|
||||
const onUpdateRefId = useCallback(
|
||||
@ -172,6 +177,7 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
{/* Data Queries */}
|
||||
<QueryEditor
|
||||
queries={dataQueries}
|
||||
expressions={expressionQueries}
|
||||
onRunQueries={runQueries}
|
||||
onChangeQueries={onChangeQueries}
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
|
@ -2,7 +2,12 @@ import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWi
|
||||
import { ClassicCondition, ExpressionQuery } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { checkForPathSeparator, queriesWithUpdatedReferences, updateMathExpressionRefs } from './util';
|
||||
import {
|
||||
checkForPathSeparator,
|
||||
getThresholdsForQueries,
|
||||
queriesWithUpdatedReferences,
|
||||
updateMathExpressionRefs,
|
||||
} from './util';
|
||||
|
||||
describe('rule-editor', () => {
|
||||
const dataSource: AlertQuery = {
|
||||
@ -236,3 +241,126 @@ describe('checkForPathSeparator', () => {
|
||||
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 { DataFrame } from '@grafana/data';
|
||||
import { DataFrame, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
|
||||
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 { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { createDagFromQueries, getOriginOfRefId } from './dag';
|
||||
|
||||
export function queriesWithUpdatedReferences(
|
||||
queries: AlertQuery[],
|
||||
previousRefId: string,
|
||||
@ -108,3 +114,161 @@ export function warningFromSeries(series: DataFrame[]): Error | 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