From 87cba8836f1a5b75fdc17b4dcc241e0b308d3ed3 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Wed, 5 Oct 2022 14:35:15 +0200 Subject: [PATCH] Alerting: Expressions pipeline redesign (#54601) --- .../src/services/__mocks__/dataSourceSrv.ts | 1 + .../unified/PanelAlertTabContent.test.tsx | 54 +-- .../alerting/unified/RuleEditor.test.tsx | 2 +- .../PanelAlertTabContent.test.tsx.snap | 119 +++++ .../alerting/unified/components/Spacer.tsx | 20 + .../alerting/unified/components/StateTag.tsx | 15 +- .../expressions/AlertConditionIndicator.tsx | 54 +++ .../components/expressions/Expression.tsx | 449 ++++++++++++++++++ .../unified/components/expressions/util.ts | 31 ++ .../components/rule-editor/AlertRuleForm.tsx | 5 +- .../rule-editor/ConditionField.test.tsx | 39 -- .../components/rule-editor/ConditionField.tsx | 95 ---- .../rule-editor/ExpressionsEditor.tsx | 68 +++ .../rule-editor/GrafanaEvaluationBehavior.tsx | 2 - .../rule-editor/QueryEditor.test.tsx | 111 ----- .../components/rule-editor/QueryEditor.tsx | 253 ++-------- .../components/rule-editor/QueryRows.tsx | 46 +- .../components/rule-editor/QueryWrapper.tsx | 24 +- .../query-and-alert-condition/Query.tsx | 61 --- .../QueryAndAlertConditionStep.tsx | 28 -- .../QueryAndExpressionsStep.tsx | 253 ++++++++++ .../__snapshots__/reducer.test.tsx.snap | 382 +++++++++++++++ .../reducer.test.tsx | 213 +++++++++ .../query-and-alert-condition/reducer.ts | 163 +++++++ .../unified/components/rule-editor/util.ts | 28 ++ .../components/rules/AlertStateTag.tsx | 7 +- .../alerting/unified/utils/rule-form.ts | 77 ++- .../expressions/components/Condition.tsx | 115 ++--- .../features/expressions/components/Math.tsx | 184 +++---- .../expressions/components/Reduce.tsx | 4 +- .../expressions/components/Resample.tsx | 4 +- .../expressions/components/Threshold.tsx | 2 +- public/app/features/expressions/types.ts | 33 +- 33 files changed, 2126 insertions(+), 816 deletions(-) create mode 100644 public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap create mode 100644 public/app/features/alerting/unified/components/Spacer.tsx create mode 100644 public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx create mode 100644 public/app/features/alerting/unified/components/expressions/Expression.tsx create mode 100644 public/app/features/alerting/unified/components/expressions/util.ts delete mode 100644 public/app/features/alerting/unified/components/rule-editor/ConditionField.test.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/QueryEditor.test.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndAlertConditionStep.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts diff --git a/packages/grafana-runtime/src/services/__mocks__/dataSourceSrv.ts b/packages/grafana-runtime/src/services/__mocks__/dataSourceSrv.ts index ee35c224792..61995de2b0a 100644 --- a/packages/grafana-runtime/src/services/__mocks__/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/__mocks__/dataSourceSrv.ts @@ -4,6 +4,7 @@ const ds1 = { type: 'prometheus', name: 'gdev-prometheus', meta: { + alerting: true, info: { logos: { small: 'http://example.com/logo.png', diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index ccb6a8184ca..935e95fbe61 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -6,7 +6,6 @@ import { byTestId } from 'testing-library-selector'; import { DataSourceApi } from '@grafana/data'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; -import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { toggleOption } from 'app/features/variables/pickers/OptionsPicker/reducer'; @@ -302,58 +301,7 @@ describe('PanelAlertTabContent', () => { expect(match).toHaveLength(2); const defaults = JSON.parse(decodeURIComponent(match![1])); - expect(defaults).toEqual({ - type: 'grafana', - folder: { id: 1, title: 'super folder' }, - queries: [ - { - refId: 'A', - queryType: '', - relativeTimeRange: { from: 21600, to: 0 }, - datasourceUid: 'mock-ds-2', - model: { - expr: 'sum(some_metric [15s])) by (app)', - refId: 'A', - datasource: { - type: 'prometheus', - uid: 'mock-ds-2', - }, - interval: '', - intervalMs: 15000, - }, - }, - { - refId: 'B', - datasourceUid: '-100', - queryType: '', - model: { - refId: 'B', - hide: false, - expression: 'A', - type: 'classic_conditions', - datasource: { - type: ExpressionDatasourceRef.type, - uid: '-100', - }, - conditions: [ - { - type: 'query', - evaluator: { params: [3], type: 'gt' }, - operator: { type: 'and' }, - query: { params: ['A'] }, - reducer: { params: [], type: 'last' }, - }, - ], - }, - }, - ], - name: 'mypanel', - condition: 'B', - annotations: [ - { key: '__dashboardUid__', value: '12' }, - { key: '__panelId__', value: '34' }, - ], - }); + expect(defaults).toMatchSnapshot(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index 3a6f11a16e8..734b6d7af52 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -296,7 +296,7 @@ describe('RuleEditor', () => { labels: { severity: 'warn', team: 'the a-team' }, for: '5m', grafana_alert: { - condition: 'B', + condition: 'C', data: getDefaultQueries(), exec_err_state: GrafanaAlertStateDecision.Error, no_data_state: 'NoData', diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap new file mode 100644 index 00000000000..f76953257c1 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = ` +Object { + "annotations": Array [ + Object { + "key": "__dashboardUid__", + "value": "12", + }, + Object { + "key": "__panelId__", + "value": "34", + }, + ], + "condition": "C", + "folder": Object { + "id": 1, + "title": "super folder", + }, + "name": "mypanel", + "queries": Array [ + Object { + "datasourceUid": "mock-ds-2", + "model": Object { + "datasource": Object { + "type": "prometheus", + "uid": "mock-ds-2", + }, + "expr": "sum(some_metric [15s])) by (app)", + "interval": "", + "intervalMs": 15000, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": Object { + "from": 21600, + "to": 0, + }, + }, + Object { + "datasourceUid": "-100", + "model": Object { + "conditions": Array [ + Object { + "evaluator": Object { + "params": Array [], + "type": "gt", + }, + "operator": Object { + "type": "and", + }, + "query": Object { + "params": Array [ + "B", + ], + }, + "reducer": Object { + "params": Array [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": Object { + "type": "__expr__", + "uid": "-100", + }, + "expression": "A", + "hide": false, + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + Object { + "datasourceUid": "-100", + "model": Object { + "conditions": Array [ + Object { + "evaluator": Object { + "params": Array [ + 0, + ], + "type": "gt", + }, + "operator": Object { + "type": "and", + }, + "query": Object { + "params": Array [ + "C", + ], + }, + "reducer": Object { + "params": Array [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": Object { + "type": "__expr__", + "uid": "-100", + }, + "expression": "B", + "hide": false, + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "type": "grafana", +} +`; diff --git a/public/app/features/alerting/unified/components/Spacer.tsx b/public/app/features/alerting/unified/components/Spacer.tsx new file mode 100644 index 00000000000..867d7cf82a6 --- /dev/null +++ b/public/app/features/alerting/unified/components/Spacer.tsx @@ -0,0 +1,20 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +/** + * A simple "flex: 1;" component you can use in combination with the Stack component(s), like so + * + * + * hello + * + * world + * + */ + +export const Spacer = () => ( + +); diff --git a/public/app/features/alerting/unified/components/StateTag.tsx b/public/app/features/alerting/unified/components/StateTag.tsx index 3d4494fd78a..41376cf6171 100644 --- a/public/app/features/alerting/unified/components/StateTag.tsx +++ b/public/app/features/alerting/unified/components/StateTag.tsx @@ -8,12 +8,13 @@ export type State = 'good' | 'bad' | 'warning' | 'neutral' | 'info'; type Props = { state: State; + size?: 'md' | 'sm'; }; -export const StateTag: FC = ({ children, state }) => { +export const StateTag: FC = ({ children, state, size = 'md' }) => { const styles = useStyles2(getStyles); - return {children || state}; + return {children || state}; }; const getStyles = (theme: GrafanaTheme2) => ({ @@ -22,10 +23,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: white; border-radius: ${theme.shape.borderRadius()}; font-size: ${theme.typography.size.sm}; - padding: ${theme.spacing(0.5, 1)}; text-transform: capitalize; line-height: 1.2; - min-width: ${theme.spacing(8)}; text-align: center; font-weight: ${theme.typography.fontWeightBold}; `, @@ -54,4 +53,12 @@ const getStyles = (theme: GrafanaTheme2) => ({ border: solid 1px ${theme.colors.primary.main}; color: ${theme.colors.primary.contrastText}; `, + md: css` + padding: ${theme.spacing(0.5, 1)}; + min-width: ${theme.spacing(8)}; + `, + sm: css` + padding: ${theme.spacing(0.3, 0.5)}; + min-width: 52px; + `, }); diff --git a/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx b/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx new file mode 100644 index 00000000000..841b3ece418 --- /dev/null +++ b/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx @@ -0,0 +1,54 @@ +import { css } from '@emotion/css'; +import React, { FC } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Badge, useStyles2 } from '@grafana/ui'; + +interface AlertConditionProps { + enabled?: boolean; + error?: Error; + warning?: Error; + onSetCondition: () => void; +} + +export const AlertConditionIndicator: FC = ({ + enabled = false, + error, + warning, + onSetCondition, +}) => { + const styles = useStyles2(getStyles); + + if (enabled && error) { + return ; + } + + if (enabled && warning) { + return ; + } + + if (enabled && !error && !warning) { + return ; + } + + if (!enabled) { + return ( +
onSetCondition()}> + Make this the alert condition +
+ ); + } + + return null; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + actionLink: css` + color: ${theme.colors.text.link}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + `, +}); diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx new file mode 100644 index 00000000000..02f465fc900 --- /dev/null +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -0,0 +1,449 @@ +import { css, cx } from '@emotion/css'; +import { capitalize, uniqueId } from 'lodash'; +import React, { FC, useCallback, useState } from 'react'; + +import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data'; +import { isTimeSeries } from '@grafana/data/src/dataframe/utils'; +import { AutoSizeInput, Icon, IconButton, Select, Stack, useStyles2 } from '@grafana/ui'; +import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions'; +import { Math } from 'app/features/expressions/components/Math'; +import { Reduce } from 'app/features/expressions/components/Reduce'; +import { Resample } from 'app/features/expressions/components/Resample'; +import { Threshold } from 'app/features/expressions/components/Threshold'; +import { ExpressionQuery, ExpressionQueryType, gelTypes } from 'app/features/expressions/types'; +import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { HoverCard } from '../HoverCard'; +import { Spacer } from '../Spacer'; +import { AlertStateTag } from '../rules/AlertStateTag'; + +import { AlertConditionIndicator } from './AlertConditionIndicator'; +import { formatLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util'; + +interface ExpressionProps { + isAlertCondition?: boolean; + data?: PanelData; + error?: Error; + warning?: Error; + queries: AlertQuery[]; + query: ExpressionQuery; + onSetCondition: (refId: string) => void; + onUpdateRefId: (oldRefId: string, newRefId: string) => void; + onRemoveExpression: (refId: string) => void; + onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void; + onChangeQuery: (query: ExpressionQuery) => void; +} + +export const Expression: FC = ({ + queries = [], + query, + data, + error, + warning, + isAlertCondition, + onSetCondition, + onUpdateRefId, + onRemoveExpression, + onUpdateExpressionType, + onChangeQuery, +}) => { + const styles = useStyles2(getStyles); + + const queryType = query?.type; + + const isLoading = data && Object.values(data).some((d) => Boolean(d) && d.state === LoadingState.Loading); + const hasResults = Array.isArray(data?.series) && !isLoading; + const series = data?.series ?? []; + + // sometime we receive results where every value is just "null" when noData occurs + const emptyResults = hasResults && isEmptySeries(series); + const isTimeSeriesResults = !emptyResults && isTimeSeries(series); + + const alertCondition = isAlertCondition ?? false; + const showSummary = isAlertCondition && hasResults; + + const groupedByState = { + [PromAlertingRuleState.Firing]: series.filter((serie) => getSeriesValue(serie) >= 1), + [PromAlertingRuleState.Inactive]: series.filter((serie) => getSeriesValue(serie) < 1), + }; + + const renderExpressionType = useCallback( + (query: ExpressionQuery) => { + // these are the refs we can choose from that don't include the current one + const availableRefIds = queries + .filter((q) => query.refId !== q.refId) + .map((q) => ({ value: q.refId, label: q.refId })); + + switch (query.type) { + case ExpressionQueryType.math: + return {}} />; + + case ExpressionQueryType.reduce: + return ; + + case ExpressionQueryType.resample: + return ; + + case ExpressionQueryType.classic: + return ; + + case ExpressionQueryType.threshold: + return ; + + default: + return <>Expression not supported: {query.type}; + } + }, + [onChangeQuery, queries] + ); + + return ( +
+
+
onRemoveExpression(query.refId)} + onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)} + onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)} + /> +
{renderExpressionType(query)}
+ {hasResults && ( +
+ {!emptyResults && isTimeSeriesResults && ( +
+ {series.map((frame, index) => ( + + ))} +
+ )} + {!emptyResults && + !isTimeSeriesResults && + series.map((frame, index) => ( + // There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles) + + ))} + {emptyResults &&
No data
} +
+ )} +
+ + onSetCondition(query.refId)} + enabled={alertCondition} + error={error} + warning={warning} + /> + + {showSummary && ( + + )} + +
+
+
+ ); +}; + +const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => { + const { mutedText } = useStyles2(getStyles); + return {`${firing} firing, ${normal} normal`}; +}; + +interface HeaderProps { + refId: string; + queryType: ExpressionQueryType; + onUpdateRefId: (refId: string) => void; + onRemoveExpression: () => void; + onUpdateExpressionType: (type: ExpressionQueryType) => void; +} + +const Header: FC = ({ refId, queryType, onUpdateRefId, onUpdateExpressionType, onRemoveExpression }) => { + const styles = useStyles2(getStyles); + /** + * There are 3 edit modes: + * + * 1. "refId": Editing the refId (ie. A -> B) + * 2. "epressionType": Editing the type of the expression (ie. Reduce -> Math) + * 3. "false": This means we're not editing either of those + */ + const [editMode, setEditMode] = useState<'refId' | 'expressionType' | false>(false); + + const editing = editMode !== false; + const editingRefId = editing && editMode === 'refId'; + const editingType = editing && editMode === 'expressionType'; + + const selectedExpressionType = gelTypes.find((o) => o.value === queryType); + + return ( +
+ + + {!editingRefId && ( +
setEditMode('refId')}> +
{refId}
+
+ )} + {editingRefId && ( + { + onUpdateRefId(event.currentTarget.value); + setEditMode(false); + }} + onFocus={(event) => event.target.select()} + onBlur={(event) => { + onUpdateRefId(event.currentTarget.value); + setEditMode(false); + }} + /> + )} + {!editingType && ( +
setEditMode('expressionType')}> +
{capitalize(queryType)}
+ setEditMode('expressionType')} /> +
+ )} + {editingType && ( + rf.value === condition.reducer.type)} - /> -
OF
- onEvaluateValueChange(event, 0)} - value={condition.evaluator.params[0]} + +
+ + {index === 0 ? ( +
WHEN
+ ) : ( + ea.value === condition.operator!.type)} + /> + )} + onEvaluateValueChange(event, 1)} - value={condition.evaluator.params[1]} +
OF
+ onEvaluateValueChange(event, 0)} - value={condition.evaluator.params[0]} - /> - ) : null} - +
+ + ef.value === condition.evaluator.type)} + /> + {isRange ? ( + <> + onEvaluateValueChange(event, 0)} + value={condition.evaluator.params[0]} + /> +
TO
+ onEvaluateValueChange(event, 1)} + value={condition.evaluator.params[1]} + /> + + ) : condition.evaluator.type !== EvalFunction.HasNoValue ? ( + onEvaluateValueChange(event, 0)} + value={condition.evaluator.params[0]} + /> + ) : null} +
+
- +
); }; diff --git a/public/app/features/expressions/components/Math.tsx b/public/app/features/expressions/components/Math.tsx index 2e798b53e59..a4d9b1986b5 100644 --- a/public/app/features/expressions/components/Math.tsx +++ b/public/app/features/expressions/components/Math.tsx @@ -1,14 +1,14 @@ import { css } from '@emotion/css'; import React, { ChangeEvent, FC } from 'react'; -import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Icon, InlineField, Stack, TextArea, useStyles2 } from '@grafana/ui'; +import { Icon, InlineField, InlineLabel, Stack, TextArea, useStyles2 } from '@grafana/ui'; +import { HoverCard } from 'app/features/alerting/unified/components/HoverCard'; import { ExpressionQuery } from '../types'; interface Props { - labelWidth: number; + labelWidth: number | 'auto'; query: ExpressionQuery; onChange: (query: ExpressionQuery) => void; onRunQuery: () => void; @@ -19,13 +19,11 @@ const mathPlaceholder = 'The sum of two scalar values: $A + $B > 10'; export const Math: FC = ({ labelWidth, onChange, query, onRunQuery }) => { - const [showHelp, toggleShowHelp] = useToggle(false); - const onExpressionChange = (event: ChangeEvent) => { onChange({ ...query, expression: event.target.value }); }; - const styles = useStyles2((theme) => getStyles(theme, showHelp)); + const styles = useStyles2(getStyles); const executeQuery = () => { if (query.expression) { @@ -36,93 +34,97 @@ export const Math: FC = ({ labelWidth, onChange, query, onRunQuery }) => return ( + +
+ Math operator +
+
+ Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C + etc. +
+ Example: $A + $B +
+
Available Math functions
+
+ + + + + + + + + + +
+
+ See our additional documentation on{' '} + + Math expressions + + . +
+ + } + > + + Expression + +
+ + } labelWidth={labelWidth} grow={true} shrink={true} - className={css` - align-items: flex-start; - flex: 0.7; - `} > - <> -