diff --git a/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx b/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx index 11d4d98e516..4bc22b49905 100644 --- a/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx +++ b/public/app/features/alerting/unified/components/expressions/AlertConditionIndicator.tsx @@ -29,7 +29,7 @@ export const AlertConditionIndicator = ({ enabled = false, error, warning, onSet if (!enabled) { return ( onSetCondition && onSetCondition()}> - Make this the alert condition + Set as alert condition ); } diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 4f49c30cee8..e082d1e8e52 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -1,16 +1,21 @@ import { css, cx } from '@emotion/css'; -import { capitalize, uniqueId } from 'lodash'; +import { uniqueId } from 'lodash'; import React, { FC, useCallback, useState } from 'react'; import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { AutoSizeInput, Button, clearButtonStyles, Icon, IconButton, Select, useStyles2 } from '@grafana/ui'; +import { AutoSizeInput, Button, clearButtonStyles, IconButton, 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 { + ExpressionQuery, + ExpressionQueryType, + expressionTypes, + getExpressionLabel, +} from 'app/features/expressions/types'; import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { usePagination } from '../../hooks/usePagination'; @@ -55,9 +60,10 @@ export const Expression: FC = ({ 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 ?? []; + const seriesCount = series.length; const alertCondition = isAlertCondition ?? false; - const showSummary = isAlertCondition && hasResults; + //const showSummary = isAlertCondition && hasResults; const groupedByState = { [PromAlertingRuleState.Firing]: series.filter((serie) => getSeriesValue(serie) >= 1), @@ -93,9 +99,18 @@ export const Expression: FC = ({ }, [onChangeQuery, queries] ); + const selectedExpressionType = expressionTypes.find((o) => o.value === queryType); + const selectedExpressionDescription = selectedExpressionType?.description ?? ''; return ( - + = ({ onRemoveExpression={() => onRemoveExpression(query.refId)} onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)} onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)} + onSetCondition={onSetCondition} + warning={warning} + error={error} + query={query} + alertCondition={alertCondition} /> - {renderExpressionType(query)} - {hasResults && } - - - - onSetCondition(query.refId)} - enabled={alertCondition} - error={error} - warning={warning} - /> - - {showSummary && ( - - )} - + + {selectedExpressionDescription} + {renderExpressionType(query)} + {hasResults && ( + <> + + + + + + + + + + > + )} ); @@ -196,9 +218,17 @@ export const ExpressionResult: FC = ({ series, isAlertCon ); }; -export const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => { +export const PreviewSummary: FC<{ firing: number; normal: number; isCondition: boolean; seriesCount: number }> = ({ + firing, + normal, + isCondition, + seriesCount, +}) => { const { mutedText } = useStyles2(getStyles); - return {`${firing} firing, ${normal} normal`}; + if (isCondition) { + return {`${seriesCount} series: ${firing} firing, ${normal} normal`}; + } + return {`${seriesCount} series`}; }; interface HeaderProps { @@ -207,9 +237,24 @@ interface HeaderProps { onUpdateRefId: (refId: string) => void; onRemoveExpression: () => void; onUpdateExpressionType: (type: ExpressionQueryType) => void; + warning?: Error; + error?: Error; + onSetCondition: (refId: string) => void; + query: ExpressionQuery; + alertCondition: boolean; } -const Header: FC = ({ refId, queryType, onUpdateRefId, onUpdateExpressionType, onRemoveExpression }) => { +const Header: FC = ({ + refId, + queryType, + onUpdateRefId, + onRemoveExpression, + warning, + onSetCondition, + alertCondition, + query, + error, +}) => { const styles = useStyles2(getStyles); const clearButton = useStyles2(clearButtonStyles); /** @@ -223,9 +268,6 @@ const Header: FC = ({ refId, queryType, onUpdateRefId, onUpdateExpr const editing = editMode !== false; const editingRefId = editing && editMode === 'refId'; - const editingType = editing && editMode === 'expressionType'; - - const selectedExpressionType = gelTypes.find((o) => o.value === queryType); return ( @@ -252,34 +294,15 @@ const Header: FC = ({ refId, queryType, onUpdateRefId, onUpdateExpr }} /> )} - {!editingType && ( - setEditMode('expressionType')} - > - {capitalize(queryType)} - setEditMode('expressionType')} /> - - )} - {editingType && ( - { - onUpdateExpressionType(selection.value ?? ExpressionQueryType.classic); - setEditMode(false); - }} - onBlur={() => { - setEditMode(false); - }} - options={gelTypes} - value={selectedExpressionType} - width={25} - /> - )} + {getExpressionLabel(queryType)} + onSetCondition(query.refId)} + enabled={alertCondition} + error={error} + warning={warning} + /> = ({ frame, index }) => return ( - + {name} @@ -396,22 +419,35 @@ const getStyles = (theme: GrafanaTheme2) => ({ expression: { wrapper: css` display: flex; - border: solid 1px ${theme.colors.border.weak}; + border: solid 1px ${theme.colors.border.medium}; + flex: 1; + flex-basis: 400px; border-radius: ${theme.shape.borderRadius()}; - max-width: 640px; `, stack: css` display: flex; flex-direction: column; flex-wrap: nowrap; gap: 0; + width: 100%; min-width: 0; // this one is important to prevent text overflow `, + classic: css` + max-width: 100%; + `, + nonClassic: css` + max-width: 640px; + `, alertCondition: css``, body: css` padding: ${theme.spacing(1)}; flex: 1; `, + description: css` + margin-bottom: ${theme.spacing(1)}; + font-size: ${theme.typography.size.xs}; + color: ${theme.colors.text.secondary}; + `, refId: css` font-weight: ${theme.typography.fontWeightBold}; color: ${theme.colors.primary.text}; diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx index 365e4f66ba9..69f7ad7a69b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx @@ -1,7 +1,8 @@ +import { css } from '@emotion/css'; import React, { useMemo } from 'react'; -import { PanelData } from '@grafana/data'; -import { Stack } from '@grafana/experimental'; +import { GrafanaTheme2, PanelData } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; @@ -36,9 +37,10 @@ export const ExpressionsEditor = ({ return isExpressionQuery(query.model) ? acc.concat(query.model) : acc; }, []); }, [queries]); + const styles = useStyles2(getStyles); return ( - + {expressionQueries.map((query) => { const data = panelData[query.refId]; @@ -63,6 +65,14 @@ export const ExpressionsEditor = ({ /> ); })} - + ); }; +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + display: flex; + gap: ${theme.spacing(2)}; + align-content: stretch; + flex-wrap: wrap; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx new file mode 100644 index 00000000000..d2d1caeec33 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx @@ -0,0 +1,64 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Icon, Toggletip, useStyles2 } from '@grafana/ui'; + +interface NeedHelpInfoProps { + contentText: string; + externalLink: string; + linkText: string; +} +export function NeedHelpInfo({ contentText, externalLink, linkText }: NeedHelpInfoProps) { + const styles = useStyles2(getStyles); + return ( + {contentText}} + title={ + + + Define query and alert condition + + } + footer={ + + + {linkText} + + + } + closeButton={true} + placement="bottom-start" + > + + + Need help? + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + mutedText: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.size.sm}; + `, + helpInfo: css` + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + font-weight: ${theme.typography.fontWeightMedium}; + margin-left: ${theme.spacing(1)}; + font-size: ${theme.typography.size.sm}; + cursor: pointer; + `, + helpInfoText: css` + margin-left: ${theme.spacing(0.5)}; + text-decoration: underline; + `, + infoLink: css` + color: ${theme.colors.text.link}; + `, +}); 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 dc52b018fd9..110505c16d1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx @@ -50,6 +50,5 @@ const getStyles = (theme: GrafanaTheme2) => ({ container: css` background-color: ${theme.colors.background.primary}; height: 100%; - max-width: ${theme.breakpoints.values.xxl}px; `, }); 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 f067b445d5f..f94c35ec9bc 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 @@ -1,12 +1,15 @@ +import { css } from '@emotion/css'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { useFormContext } from 'react-hook-form'; -import { getDefaultRelativeTimeRange } from '@grafana/data'; +import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Stack } from '@grafana/experimental'; import { config, getDataSourceSrv } from '@grafana/runtime'; -import { Alert, Button, Field, InputControl, Tooltip } from '@grafana/ui'; +import { Alert, Button, Dropdown, Field, Icon, InputControl, Menu, MenuItem, Tooltip, useStyles2 } from '@grafana/ui'; +import { H5 } from '@grafana/ui/src/unstable'; import { isExpressionQuery } from 'app/features/expressions/guards'; +import { ExpressionQueryType, expressionTypes } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; @@ -15,6 +18,7 @@ import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource import { isPromOrLokiQuery } from '../../../utils/rule-form'; import { ExpressionEditor } from '../ExpressionEditor'; import { ExpressionsEditor } from '../ExpressionsEditor'; +import { NeedHelpInfo } from '../NeedHelpInfo'; import { QueryEditor } from '../QueryEditor'; import { RecordingRuleEditor } from '../RecordingRuleEditor'; import { RuleEditorSection } from '../RuleEditorSection'; @@ -223,8 +227,17 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P } }, [condition, queries, handleSetCondition]); + const onClickType = useCallback( + (type: ExpressionQueryType) => { + dispatch(addNewExpression(type)); + }, + [dispatch] + ); + + const styles = useStyles2(getStyles); + return ( - + {/* This is the PromQL Editor for recording rules */} @@ -266,6 +279,21 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P {isGrafanaManagedType && ( {/* Data Queries */} + + + Define queries and/or expressions and then choose one of them as the alert rule condition. This is the + threshold that an alert rule must meet or exceed in order to fire. + + + + + + + { + dispatch(addNewDataQuery()); + }} + variant="secondary" + aria-label={selectors.components.QueryTab.addQuery} + disabled={noCompatibleDataSources} + className={styles.addQueryButton} + > + Add query + + {/* Expression Queries */} + Expressions + Manipulate data returned from queries with math and other operations {/* action buttons */} - - { - dispatch(addNewDataQuery()); - }} - variant="secondary" - aria-label={selectors.components.QueryTab.addQuery} - disabled={noCompatibleDataSources} - > - Add query - - - - {config.expressionsEnabled && ( - { - dispatch(addNewExpression()); - }} - variant="secondary" - > - Add expression - - )} + {config.expressionsEnabled && } {isPreviewLoading && ( @@ -346,3 +364,56 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P ); }; + +function TypeSelectorButton({ onClickType }: { onClickType: (type: ExpressionQueryType) => void }) { + const newMenu = ( + + {expressionTypes.map((type) => ( + + onClickType(type.value ?? ExpressionQueryType.math)} + label={type.label ?? ''} + /> + + ))} + + ); + + return ( + + + Add expression + + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + mutedText: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.size.sm}; + margin-top: ${theme.spacing(-1)}; + `, + addQueryButton: css` + width: fit-content; + `, + helpInfo: css` + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + font-weight: ${theme.typography.fontWeightMedium}; + margin-left: ${theme.spacing(1)}; + font-size: ${theme.typography.size.sm}; + cursor: pointer; + `, + helpInfoText: css` + margin-left: ${theme.spacing(0.5)}; + text-decoration: underline; + `, + infoLink: css` + color: ${theme.colors.text.link}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx index f452aacbeed..1349fbcb18d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx @@ -1,7 +1,7 @@ import { getDefaultRelativeTimeRange, RelativeTimeRange } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime/src/services/__mocks__/dataSourceSrv'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; -import { ExpressionQuery, ExpressionQueryType, ExpressionDatasourceUID } from 'app/features/expressions/types'; +import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { defaultCondition } from 'app/features/expressions/utils/expressionTypes'; import { AlertQuery } from 'app/types/unified-alerting-dto'; @@ -113,7 +113,7 @@ describe('Query and expressions reducer', () => { queries: [alertQuery], }; - const newState = queriesAndExpressionsReducer(initialState, addNewExpression()); + const newState = queriesAndExpressionsReducer(initialState, addNewExpression(ExpressionQueryType.math)); expect(newState.queries).toHaveLength(2); expect(newState).toMatchSnapshot(); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index 00bdb0a3ef0..45e9d0861cf 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -5,7 +5,7 @@ import { getNextRefIdChar } from 'app/core/utils/query'; import { findDataSourceFromExpressionRecursive } from 'app/features/alerting/utils/dataSourceFromExpression'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionQuery, ExpressionQueryType, ExpressionDatasourceUID } from 'app/features/expressions/types'; +import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { defaultCondition } from 'app/features/expressions/utils/expressionTypes'; import { AlertQuery } from 'app/types/unified-alerting-dto'; @@ -33,7 +33,7 @@ export const duplicateQuery = createAction('duplicateQuery'); export const addNewDataQuery = createAction('addNewDataQuery'); export const setDataQueries = createAction('setDataQueries'); -export const addNewExpression = createAction('addNewExpression'); +export const addNewExpression = createAction('addNewExpression'); export const removeExpression = createAction('removeExpression'); export const updateExpression = createAction('updateExpression'); export const updateExpressionRefId = createAction<{ oldRefId: string; newRefId: string }>('updateExpressionRefId'); @@ -98,11 +98,11 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder // expressions actions builder - .addCase(addNewExpression, (state) => { + .addCase(addNewExpression, (state, { payload }) => { state.queries = addQuery(state.queries, { datasourceUid: ExpressionDatasourceUID, model: expressionDatasource.newQuery({ - type: ExpressionQueryType.math, + type: payload, conditions: [{ ...defaultCondition, query: { params: [] } }], expression: '', }), diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index a72b25f10ed..781e9a3ef05 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -8,7 +8,7 @@ import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; import { Threshold } from './components/Threshold'; -import { ExpressionQuery, ExpressionQueryType, gelTypes } from './types'; +import { ExpressionQuery, ExpressionQueryType, expressionTypes } from './types'; import { getDefaults } from './utils/expressionTypes'; type Props = QueryEditorProps, ExpressionQuery>; @@ -92,12 +92,12 @@ export function ExpressionQueryEditor(props: Props) { } }; - const selected = gelTypes.find((o) => o.value === query.type); + const selected = expressionTypes.find((o) => o.value === query.type); return ( - + {renderExpressionType()} diff --git a/public/app/features/expressions/components/Reduce.tsx b/public/app/features/expressions/components/Reduce.tsx index 30255772de4..290d6afde10 100644 --- a/public/app/features/expressions/components/Reduce.tsx +++ b/public/app/features/expressions/components/Reduce.tsx @@ -69,14 +69,14 @@ export const Reduce = ({ labelWidth = 'auto', onChange, refIds, query }: Props) return ( <> - - - + + + diff --git a/public/app/features/expressions/components/Threshold.tsx b/public/app/features/expressions/components/Threshold.tsx index d789b695c60..1c2c81dd6ad 100644 --- a/public/app/features/expressions/components/Threshold.tsx +++ b/public/app/features/expressions/components/Threshold.tsx @@ -67,41 +67,45 @@ export const Threshold = ({ labelWidth, onChange, refIds, query }: Props) => { condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange; return ( - - - - - - {isRange ? ( - <> + <> + + + + + + + + {isRange ? ( + <> + onEvaluateValueChange(event, 0)} + defaultValue={condition.evaluator.params[0]} + /> + TO + onEvaluateValueChange(event, 1)} + defaultValue={condition.evaluator.params[1]} + /> + > + ) : ( onEvaluateValueChange(event, 0)} - defaultValue={condition.evaluator.params[0]} + defaultValue={conditions[0].evaluator.params[0] || 0} /> - TO - onEvaluateValueChange(event, 1)} - defaultValue={condition.evaluator.params[1]} - /> - > - ) : ( - onEvaluateValueChange(event, 0)} - defaultValue={conditions[0].evaluator.params[0] || 0} - /> - )} - + )} + + > ); }; diff --git a/public/app/features/expressions/types.ts b/public/app/features/expressions/types.ts index c3b1ea2c091..e9336353c18 100644 --- a/public/app/features/expressions/types.ts +++ b/public/app/features/expressions/types.ts @@ -15,7 +15,22 @@ export enum ExpressionQueryType { threshold = 'threshold', } -export const gelTypes: Array> = [ +export const getExpressionLabel = (type: ExpressionQueryType) => { + switch (type) { + case ExpressionQueryType.math: + return 'Math'; + case ExpressionQueryType.reduce: + return 'Reduce'; + case ExpressionQueryType.resample: + return 'Resample'; + case ExpressionQueryType.classic: + return 'Classic condition'; + case ExpressionQueryType.threshold: + return 'Threshold'; + } +}; + +export const expressionTypes: Array> = [ { value: ExpressionQueryType.math, label: 'Math',