Alerting: Improve threshold displays (#60046)

This commit is contained in:
Gilles De Mey 2022-12-22 16:28:17 +01:00 committed by GitHub
parent 58716de073
commit e9fe9baf66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 722 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
},
}
`;

View File

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

View File

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

View File

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

View File

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

View File

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