From 7114c51f9ff60e6f3e3b61ea5cad4cb2d08f3a3d Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Fri, 23 Sep 2022 10:05:08 +0200 Subject: [PATCH] Alerting: Add alert preview to cloud rules editor (#54950) --- .../features/alerting/unified/api/preview.ts | 31 ++- .../admin/ExternalAlertmanagerDataSources.tsx | 2 +- .../rule-editor/CloudAlertPreview.tsx | 108 +++++++++++ .../rule-editor/ExpressionEditor.tsx | 64 ++++++- .../components/rule-editor/PreviewRule.tsx | 8 +- .../components/rule-editor/preview.test.ts | 181 ++++++++++++++++++ .../unified/components/rule-editor/preview.ts | 46 +++++ .../query-and-alert-condition/Query.tsx | 3 +- .../components/rules/AlertStateTag.tsx | 4 +- .../alerting/unified/types/preview.ts | 1 + .../features/alerting/unified/utils/labels.ts | 7 + .../features/alerting/unified/utils/query.ts | 26 +-- public/app/types/unified-alerting-dto.ts | 4 + 13 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-editor/CloudAlertPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/preview.test.ts create mode 100644 public/app/features/alerting/unified/components/rule-editor/preview.ts create mode 100644 public/app/features/alerting/unified/utils/labels.ts diff --git a/public/app/features/alerting/unified/api/preview.ts b/public/app/features/alerting/unified/api/preview.ts index 20ba91453b9..ffb5f2dd90f 100644 --- a/public/app/features/alerting/unified/api/preview.ts +++ b/public/app/features/alerting/unified/api/preview.ts @@ -12,52 +12,53 @@ import { import { getBackendSrv, toDataQueryError } from '@grafana/runtime'; import { - CloudPreviewRuleRequest, - GrafanaPreviewRuleRequest, isCloudPreviewRequest, isGrafanaPreviewRequest, PreviewRuleRequest, PreviewRuleResponse, } from '../types/preview'; import { RuleFormType } from '../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; export function previewAlertRule(request: PreviewRuleRequest): Observable { if (isCloudPreviewRequest(request)) { - return previewCloudAlertRule(request); + return fetchAlertRulePreview(request, request.dataSourceUid, RuleFormType.cloudAlerting); } if (isGrafanaPreviewRequest(request)) { - return previewGrafanaAlertRule(request); + return fetchAlertRulePreview(request, GRAFANA_RULES_SOURCE_NAME, RuleFormType.grafana); } throw new Error('unsupported preview rule request'); } -type GrafanaPreviewRuleResponse = { +type AlertRulePreviewResponse = { instances: DataFrameJSON[]; }; -function previewGrafanaAlertRule(request: GrafanaPreviewRuleRequest): Observable { - const type = RuleFormType.grafana; - +function fetchAlertRulePreview( + request: PreviewRuleRequest, + dataSourceUid: string, + ruleType: RuleFormType +): Observable { return withLoadingIndicator({ - whileLoading: createResponse(type), + whileLoading: createResponse(ruleType), source: getBackendSrv() - .fetch({ + .fetch({ method: 'POST', - url: `/api/v1/rule/test/grafana`, + url: `/api/v1/rule/test/${dataSourceUid}`, data: request, }) .pipe( map(({ data }) => { - return createResponse(type, { + return createResponse(ruleType, { state: LoadingState.Done, series: data.instances.map(dataFrameFromJSON), }); }), catchError((error: Error) => { return of( - createResponse(type, { + createResponse(ruleType, { state: LoadingState.Error, error: toDataQueryError(error), }) @@ -79,7 +80,3 @@ function createResponse(ruleType: RuleFormType, data: Partial = {}): }, }; } - -function previewCloudAlertRule(request: CloudPreviewRuleRequest): Observable { - throw new Error('preview for cloud alerting rules is not implemented'); -} diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx index a0325ae9487..633e1519044 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx @@ -93,7 +93,7 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd {url} - Go to datasouce + Go to datasource diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudAlertPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudAlertPreview.tsx new file mode 100644 index 00000000000..e86c1805fe9 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/CloudAlertPreview.tsx @@ -0,0 +1,108 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataFrame, GrafanaTheme2 } from '@grafana/data/src'; +import { Icon, TagList, Tooltip, useStyles2 } from '@grafana/ui/src'; + +import { labelsToTags } from '../../utils/labels'; +import { AlertStateTag } from '../rules/AlertStateTag'; + +import { mapDataFrameToAlertPreview } from './preview'; + +interface CloudAlertPreviewProps { + preview: DataFrame; +} + +export function CloudAlertPreview({ preview }: CloudAlertPreviewProps) { + const styles = useStyles2(getStyles); + const alertPreview = mapDataFrameToAlertPreview(preview); + + return ( + + + + + + + + + + + {alertPreview.instances.map(({ state, info, labels }, index) => { + const instanceTags = labelsToTags(labels); + + return ( + + + + + + ); + })} + +
+
Alerts preview
+ Preview based on the result of running the query for this moment. +
StateLabelsInfo
{} + + + {info && ( + + + + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css` + width: 100%; + margin: ${theme.spacing(2, 0)}; + + caption { + caption-side: top; + color: ${theme.colors.text.primary}; + + & > span { + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; + } + } + + td, + th { + padding: ${theme.spacing(1, 1)}; + } + + td + td, + th + th { + padding-left: ${theme.spacing(3)}; + } + + thead th { + &:nth-child(1) { + width: 80px; + } + + &:nth-child(2) { + width: auto; + } + + &:nth-child(3) { + width: 40px; + } + } + + td:nth-child(3) { + text-align: center; + } + + tbody tr:nth-child(2n + 1) { + background-color: ${theme.colors.background.secondary}; + } + `, + tagList: css` + justify-content: flex-start; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx index 9427778a4fd..ae064653ded 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx @@ -1,12 +1,17 @@ +import { css } from '@emotion/css'; import { noop } from 'lodash'; import React, { FC, useCallback, useMemo } from 'react'; import { useAsync } from 'react-use'; -import { CoreApp, DataQuery } from '@grafana/data'; +import { CoreApp, DataQuery, GrafanaTheme2, LoadingState } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; +import { Alert, Button, useStyles2 } from '@grafana/ui'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; +import { CloudAlertPreview } from './CloudAlertPreview'; +import { usePreview } from './PreviewRule'; + export interface ExpressionEditorProps { value?: string; onChange: (value: string) => void; @@ -14,8 +19,10 @@ export interface ExpressionEditorProps { } export const ExpressionEditor: FC = ({ value, onChange, dataSourceName }) => { + const styles = useStyles2(getStyles); + const { mapToValue, mapToQuery } = useQueryMappers(dataSourceName); - const query = mapToQuery({ refId: 'A', hide: false }, value); + const dataQuery = mapToQuery({ refId: 'A', hide: false }, value); const { error, @@ -32,6 +39,12 @@ export const ExpressionEditor: FC = ({ value, onChange, d [onChange, mapToValue] ); + const [alertPreview, onPreview] = usePreview(); + + const onRunQueriesClick = async () => { + onPreview(); + }; + if (loading || dataSource?.name !== dataSourceName) { return null; } @@ -41,20 +54,51 @@ export const ExpressionEditor: FC = ({ value, onChange, d return
Could not load query editor due to: {errorMessage}
; } + const previewLoaded = alertPreview?.data.state === LoadingState.Done; + const QueryEditor = dataSource?.components?.QueryEditor; + // The Preview endpoint returns the preview as a single-element array of data frames + const previewDataFrame = alertPreview?.data?.series?.find((s) => s.name === 'evaluation results'); + // The preview API returns arrays with empty elements when there are no firing alerts + const previewHasAlerts = previewDataFrame && previewDataFrame.fields.some((field) => field.values.length > 0); + return ( - + <> + + +
+ + {previewLoaded && !previewHasAlerts && ( + + There are no firing alerts for your query. + + )} + {previewHasAlerts && } +
+ ); }; +const getStyles = (theme: GrafanaTheme2) => ({ + preview: css` + padding: ${theme.spacing(2, 0)}; + max-width: ${theme.breakpoints.values.xl}px; + `, + previewAlert: css` + margin: ${theme.spacing(1, 0)}; + `, +}); + type QueryMappers = { mapToValue: (query: T) => string; mapToQuery: (existing: T, value: string | undefined) => T; diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx index 8ff4bbb30da..dcb979db88c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx @@ -5,6 +5,7 @@ import { useMountedState } from 'react-use'; import { takeWhile } from 'rxjs/operators'; import { dateTimeFormatISO, GrafanaTheme2, LoadingState } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; import { Alert, Button, HorizontalGroup, useStyles2 } from '@grafana/ui'; import { previewAlertRule } from '../../api/preview'; @@ -48,7 +49,7 @@ export function PreviewRule(): React.ReactElement | null { ); } -function usePreview(): [PreviewRuleResponse | undefined, () => void] { +export function usePreview(): [PreviewRuleResponse | undefined, () => void] { const [preview, setPreview] = useState(); const { getValues } = useFormContext(); const isMounted = useMountedState(); @@ -72,10 +73,15 @@ function usePreview(): [PreviewRuleResponse | undefined, () => void] { function createPreviewRequest(values: any[]): PreviewRuleRequest { const [type, dataSourceName, condition, queries, expression] = values; + const dsSettings = getDataSourceSrv().getInstanceSettings(dataSourceName); + if (!dsSettings) { + throw new Error(`Cannot find data source settings for ${dataSourceName}`); + } switch (type) { case RuleFormType.cloudAlerting: return { + dataSourceUid: dsSettings.uid, dataSourceName, expr: expression, }; diff --git a/public/app/features/alerting/unified/components/rule-editor/preview.test.ts b/public/app/features/alerting/unified/components/rule-editor/preview.test.ts new file mode 100644 index 00000000000..c245c3e9adf --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/preview.test.ts @@ -0,0 +1,181 @@ +import { DataFrame, FieldType, MutableDataFrame } from '@grafana/data'; + +import { mapDataFrameToAlertPreview } from './preview'; + +describe('mapDataFrameToAlertPreview', () => { + it('should convert data frame fields into set of labels, state and info', () => { + const frame: DataFrame = new MutableDataFrame({ + fields: [ + { + name: 'severity', + type: FieldType.string, + values: ['error', 'error', 'warning', 'warning'], + }, + { + name: 'node', + type: FieldType.string, + values: ['cpu-0', 'cpu-1', 'cpu-0', 'cpu-1'], + }, + { + name: 'State', + type: FieldType.string, + values: ['Alerting', 'Alerting', 'Alerting', 'Alerting'], + }, + { + name: 'Info', + type: FieldType.string, + values: ['value=0.34', 'value=0.2', 'value=0.1', 'value=0.66'], + }, + ], + }); + + const alertPreview = mapDataFrameToAlertPreview(frame); + + expect(alertPreview.instances).toHaveLength(4); + expect(alertPreview.instances[0]).toEqual({ + state: 'Alerting', + info: 'value=0.34', + labels: { severity: 'error', node: 'cpu-0' }, + }); + expect(alertPreview.instances[1]).toEqual({ + state: 'Alerting', + info: 'value=0.2', + labels: { severity: 'error', node: 'cpu-1' }, + }); + expect(alertPreview.instances[2]).toEqual({ + state: 'Alerting', + info: 'value=0.1', + labels: { severity: 'warning', node: 'cpu-0' }, + }); + expect(alertPreview.instances[3]).toEqual({ + state: 'Alerting', + info: 'value=0.66', + labels: { severity: 'warning', node: 'cpu-1' }, + }); + }); + + it('should return 0 instances if there is no State field', () => { + const frame: DataFrame = new MutableDataFrame({ + fields: [ + { + name: 'severity', + type: FieldType.string, + values: ['error', 'warning'], + }, + { + name: 'Info', + type: FieldType.string, + values: ['value=0.34', 'value=0.2'], + }, + ], + }); + + const alertPreview = mapDataFrameToAlertPreview(frame); + + expect(alertPreview.instances).toHaveLength(0); + }); + + it('should return instances with labels if there is no Info field', () => { + const frame: DataFrame = new MutableDataFrame({ + fields: [ + { + name: 'severity', + type: FieldType.string, + values: ['error', 'warning'], + }, + { + name: 'State', + type: FieldType.string, + values: ['Alerting', 'Alerting'], + }, + ], + }); + + const alertPreview = mapDataFrameToAlertPreview(frame); + + expect(alertPreview.instances).toHaveLength(2); + expect(alertPreview.instances[0]).toEqual({ + state: 'Alerting', + labels: { severity: 'error' }, + }); + expect(alertPreview.instances[1]).toEqual({ + state: 'Alerting', + labels: { severity: 'warning' }, + }); + }); + + it('should limit number of instances to number of State values', () => { + const frame: DataFrame = new MutableDataFrame({ + fields: [ + { + name: 'severity', + type: FieldType.string, + values: ['critical', 'error', 'warning', 'info'], + }, + { + name: 'State', + type: FieldType.string, + values: ['Alerting', 'Alerting'], + }, + ], + }); + + const alertPreview = mapDataFrameToAlertPreview(frame); + + expect(alertPreview.instances).toHaveLength(2); + expect(alertPreview.instances[0]).toEqual({ state: 'Alerting', labels: { severity: 'critical' } }); + expect(alertPreview.instances[1]).toEqual({ state: 'Alerting', labels: { severity: 'error' } }); + }); + + // Just to be resistant to incomplete data in data frames + it('should return instances with labels if number of fields values do not match', () => { + const frame: DataFrame = new MutableDataFrame({ + fields: [ + { + name: 'severity', + type: FieldType.string, + values: ['error', 'error', 'warning', 'warning'], + }, + { + name: 'node', + type: FieldType.string, + values: ['cpu-0', 'cpu-1', 'cpu-1'], + }, + { + name: 'State', + type: FieldType.string, + values: ['Alerting', 'Alerting', 'Alerting', 'Alerting'], + }, + { + name: 'Info', + type: FieldType.string, + values: ['value=0.34', 'value=0.2', 'value=0.66'], + }, + ], + }); + + const alertPreview = mapDataFrameToAlertPreview(frame); + + expect(alertPreview.instances).toHaveLength(4); + expect(alertPreview.instances[0]).toEqual({ + state: 'Alerting', + info: 'value=0.34', + labels: { severity: 'error', node: 'cpu-0' }, + }); + expect(alertPreview.instances[1]).toEqual({ + state: 'Alerting', + info: 'value=0.2', + labels: { severity: 'error', node: 'cpu-1' }, + }); + expect(alertPreview.instances[2]).toEqual({ + state: 'Alerting', + info: 'value=0.66', + labels: { severity: 'warning', node: 'cpu-1' }, + }); + expect(alertPreview.instances[3]).toEqual({ + state: 'Alerting', + info: undefined, + labels: { severity: 'warning', node: undefined }, + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/preview.ts b/public/app/features/alerting/unified/components/rule-editor/preview.ts new file mode 100644 index 00000000000..1e535d3c4b2 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/preview.ts @@ -0,0 +1,46 @@ +import { DataFrame } from '@grafana/data'; + +import { GrafanaAlertState, isGrafanaAlertState, Labels } from '../../../../../types/unified-alerting-dto'; + +interface AlertPreviewInstance { + state: GrafanaAlertState; + info?: string; + labels: Labels; +} + +interface AlertPreview { + instances: AlertPreviewInstance[]; +} + +// Alerts previews come in a DataFrame format which is more suited for displaying time series data +// In order to display a list of tags we need to transform DataFrame into set of labels +export function mapDataFrameToAlertPreview({ fields }: DataFrame): AlertPreview { + const labelFields = fields.filter((field) => !['State', 'Info'].includes(field.name)); + const stateFieldIndex = fields.findIndex((field) => field.name === 'State'); + const infoFieldIndex = fields.findIndex((field) => field.name === 'Info'); + + const labelIndexes = labelFields.map((labelField) => fields.indexOf(labelField)); + + const instanceStatusCount = fields[stateFieldIndex]?.values.length ?? 0; + + const instances: AlertPreviewInstance[] = []; + + for (let index = 0; index < instanceStatusCount; index++) { + const labelValues = labelIndexes.map((labelIndex) => [ + fields[labelIndex].name, + fields[labelIndex].values.get(index), + ]); + const state = fields[stateFieldIndex]?.values?.get(index); + const info = fields[infoFieldIndex]?.values?.get(index); + + if (isGrafanaAlertState(state)) { + instances.push({ + state: state, + info: info, + labels: Object.fromEntries(labelValues), + }); + } + } + + return { instances }; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx index 0e027c81048..5b1ebd874df 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx @@ -14,8 +14,7 @@ export const Query: FC = () => { formState: { errors }, } = useFormContext(); - const type = watch('type'); - const dataSourceName = watch('dataSourceName'); + const [type, dataSourceName] = watch(['type', 'dataSourceName']); const isGrafanaManagedType = type === RuleFormType.grafana; const isCloudAlertRuleType = type === RuleFormType.cloudAlerting; diff --git a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx index 70dcccbd547..375b07f3aad 100644 --- a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx @@ -1,12 +1,12 @@ import React, { FC } from 'react'; import { AlertState } from '@grafana/data'; -import { GrafanaAlertStateWithReason, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { GrafanaAlertState, GrafanaAlertStateWithReason, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { alertStateToReadable, alertStateToState } from '../../utils/rules'; import { StateTag } from '../StateTag'; interface Props { - state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState; + state: PromAlertingRuleState | GrafanaAlertState | GrafanaAlertStateWithReason | AlertState; } export const AlertStateTag: FC = ({ state }) => ( diff --git a/public/app/features/alerting/unified/types/preview.ts b/public/app/features/alerting/unified/types/preview.ts index 13e6315cb92..991d4fccfe9 100644 --- a/public/app/features/alerting/unified/types/preview.ts +++ b/public/app/features/alerting/unified/types/preview.ts @@ -14,6 +14,7 @@ export type GrafanaPreviewRuleRequest = { }; export type CloudPreviewRuleRequest = { + dataSourceUid: string; dataSourceName: string; expr: string; }; diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts new file mode 100644 index 00000000000..58fa467f698 --- /dev/null +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -0,0 +1,7 @@ +import { Labels } from '../../../../types/unified-alerting-dto'; + +export function labelsToTags(labels: Labels) { + return Object.entries(labels) + .map(([label, value]) => `${label}=${value}`) + .sort(); +} diff --git a/public/app/features/alerting/unified/utils/query.ts b/public/app/features/alerting/unified/utils/query.ts index 7fd17d02703..7acba5a623a 100644 --- a/public/app/features/alerting/unified/utils/query.ts +++ b/public/app/features/alerting/unified/utils/query.ts @@ -23,23 +23,25 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null if (isCloudRulesSource(rulesSource)) { const model = cloudAlertRuleToModel(rulesSource, combinedRule); - return [ - { - refId: model.refId, - datasourceUid: rulesSource.uid, - queryType: '', - model, - relativeTimeRange: { - from: 360, - to: 0, - }, - }, - ]; + return [dataQueryToAlertQuery(model, rulesSource.uid)]; } return []; } +export function dataQueryToAlertQuery(dataQuery: DataQuery, dataSourceUid: string): AlertQuery { + return { + refId: dataQuery.refId, + datasourceUid: dataSourceUid, + queryType: '', + model: dataQuery, + relativeTimeRange: { + from: 360, + to: 0, + }, + }; +} + function cloudAlertRuleToModel(dsSettings: DataSourceInstanceSettings, rule: CombinedRule): DataQuery { const refId = 'A'; diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 6f0604b9cfb..f67e87332fe 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -23,6 +23,10 @@ type GrafanaAlertStateReason = ` (${string})` | ''; export type GrafanaAlertStateWithReason = `${GrafanaAlertState}${GrafanaAlertStateReason}`; +export function isGrafanaAlertState(state: string): state is GrafanaAlertState { + return Object.values(GrafanaAlertState).some((promState) => promState === state); +} + /** We need this to disambiguate the union PromAlertingRuleState | GrafanaAlertStateWithReason */ export function isAlertStateWithReason(