From e9fe9baf661d40eb0b0695c007e994f65f632043 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 22 Dec 2022 16:28:17 +0100 Subject: [PATCH] Alerting: Improve threshold displays (#60046) --- .../components/rule-editor/QueryEditor.tsx | 3 + .../components/rule-editor/QueryRows.tsx | 67 +------ .../components/rule-editor/QueryWrapper.tsx | 9 +- .../components/rule-editor/VizWrapper.tsx | 16 +- .../__snapshots__/util.test.ts.snap | 143 +++++++++++++++ .../components/rule-editor/dag.test.ts | 143 +++++++++++++++ .../unified/components/rule-editor/dag.ts | 108 ++++++++++++ .../QueryAndExpressionsStep.tsx | 6 + .../components/rule-editor/util.test.ts | 130 +++++++++++++- .../unified/components/rule-editor/util.ts | 166 +++++++++++++++++- 10 files changed, 722 insertions(+), 69 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap create mode 100644 public/app/features/alerting/unified/components/rule-editor/dag.test.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/dag.ts diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx index d41a10472b4..e733f807e62 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx @@ -10,6 +10,7 @@ import { QueryRows } from './QueryRows'; interface Props { panelData: Record; queries: AlertQuery[]; + expressions: AlertQuery[]; onRunQueries: () => void; onChangeQueries: (queries: AlertQuery[]) => void; onDuplicateQuery: (query: AlertQuery) => void; @@ -19,6 +20,7 @@ interface Props { export const QueryEditor: FC = ({ queries, + expressions, panelData, onRunQueries, onChangeQueries, @@ -33,6 +35,7 @@ export const QueryEditor: FC = ({ ; onRunQueries: () => void; @@ -118,53 +110,9 @@ export class QueryRows extends PureComponent { return getDataSourceSrv().getInstanceSettings(query.datasourceUid); }; - getThresholdsForQueries = (queries: AlertQuery[]): Record => { - const record: Record = {}; - - 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 ( @@ -215,7 +163,8 @@ export class QueryRows extends PureComponent { 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} diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index a8b3e954acc..a373e91ffee 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -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 = ({ query, queries, thresholds, + thresholdsType, onChangeThreshold, condition, onSetCondition, @@ -141,7 +143,8 @@ export const QueryWrapper: FC = ({ changePanel={changePluginId} currentPanel={pluginId} thresholds={thresholds} - onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : noop} + thresholdsType={thresholdsType} + onThresholdsChange={onChangeThreshold ? (thresholds) => onChangeThreshold(thresholds, index) : undefined} /> ) : null } diff --git a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx index c6b0ffda3a0..667d1d61658 100644 --- a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx @@ -17,12 +17,19 @@ interface Props { currentPanel: SupportedPanelPlugins; changePanel: (panel: SupportedPanelPlugins) => void; thresholds?: ThresholdsConfig; + thresholdsType?: GraphTresholdsStyleMode; onThresholdsChange?: (thresholds: ThresholdsConfig) => void; } type PanelFieldConfig = FieldConfigSource; -export const VizWrapper: FC = ({ data, currentPanel, changePanel, onThresholdsChange, thresholds }) => { +export const VizWrapper: FC = ({ + data, + currentPanel, + changePanel, + thresholds, + thresholdsType = GraphTresholdsStyleMode.Line, +}) => { const [options, setOptions] = useState({ frameIndex: 0, showHeader: true, @@ -42,21 +49,20 @@ export const VizWrapper: FC = ({ 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) { diff --git a/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap b/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap new file mode 100644 index 00000000000..a6154bf3fab --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap @@ -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", + }, +} +`; diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts new file mode 100644 index 00000000000..2b173741720 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts @@ -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"`); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.ts b/public/app/features/alerting/unified/components/rule-editor/dag.ts new file mode 100644 index 00000000000..407fe2f0912 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/dag.ts @@ -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, next: Parameters) => { + 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(/\$\{(?[a-zA-Z0-9_ ]+?)\}/gm); + const r2 = new RegExp(/\$(?[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(); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index 974635f8352..96b113d21c2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -97,6 +97,11 @@ export const QueryAndExpressionsStep: FC = ({ 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 = ({ editingExistingRule }) => { {/* Data Queries */} { 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]; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index 5d833ab9de8..a16eee6e922 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -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 + ); +}