diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 7ace16115e9..7ae5541dcce 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -20,3 +20,4 @@ export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks'; export { DocsId } from './docs'; export { makeClassES5Compatible } from './makeClassES5Compatible'; export { anyToNumber } from './anyToNumber'; +export { withLoadingIndicator, WithLoadingIndicatorOptions } from './withLoadingIndicator'; diff --git a/packages/grafana-data/src/utils/withLoadingIndicator.ts b/packages/grafana-data/src/utils/withLoadingIndicator.ts new file mode 100644 index 00000000000..d4a6280824b --- /dev/null +++ b/packages/grafana-data/src/utils/withLoadingIndicator.ts @@ -0,0 +1,17 @@ +import { merge, Observable, timer } from 'rxjs'; +import { mapTo, takeUntil } from 'rxjs/operators'; + +/** + * @internal + */ +export type WithLoadingIndicatorOptions = { + whileLoading: T; + source: Observable; +}; + +/** + * @internal + */ +export function withLoadingIndicator({ whileLoading, source }: WithLoadingIndicatorOptions): Observable { + return merge(timer(200).pipe(mapTo(whileLoading), takeUntil(source)), source); +} diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index 5adb6cc2d34..5e96826f1d8 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "testing" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr/mathexp" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" @@ -202,7 +203,7 @@ func TestConditionsCmdExecute(t *testing.T) { vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + valBasedSeriesWithLabels(data.Labels{"h": "1"}, ptr.Float64(30), ptr.Float64(40)), valBasedSeries(ptr.Float64(0), ptr.Float64(10)), }, }, @@ -218,7 +219,7 @@ func TestConditionsCmdExecute(t *testing.T) { }}, resultNumber: func() mathexp.Number { v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(35)}}) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(35), Labels: data.Labels{"h": "1"}}}) return v }, }, diff --git a/pkg/expr/classic/reduce.go b/pkg/expr/classic/reduce.go index 39991a2e262..3a6fda0cc05 100644 --- a/pkg/expr/classic/reduce.go +++ b/pkg/expr/classic/reduce.go @@ -24,6 +24,11 @@ func (cr classicReducer) ValidReduceFunc() bool { //nolint: gocyclo func (cr classicReducer) Reduce(series mathexp.Series) mathexp.Number { num := mathexp.NewNumber("", nil) + + if series.GetLabels() != nil { + num.SetLabels(series.GetLabels().Copy()) + } + num.SetValue(nil) if series.Len() == 0 { diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go index bb82c5e41fd..7361e9295c6 100644 --- a/pkg/expr/classic/reduce_test.go +++ b/pkg/expr/classic/reduce_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr/mathexp" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" @@ -408,6 +409,17 @@ func valBasedSeries(vals ...*float64) mathexp.Series { return newSeries } +func valBasedSeriesWithLabels(l data.Labels, vals ...*float64) mathexp.Series { + newSeries := mathexp.NewSeries("", l, 0, false, 1, true, len(vals)) + for idx, f := range vals { + err := newSeries.SetPoint(idx, unixTimePointer(int64(idx)), f) + if err != nil { + panic(err) + } + } + return newSeries +} + func unixTimePointer(sec int64) *time.Time { t := time.Unix(sec, 0) return &t diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 396043152bb..593a5bd4da2 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -331,12 +331,21 @@ func (evalResults Results) AsDataFrame() data.Frame { frame.Fields = append(frame.Fields, data.NewField(lKey, nil, make([]string, fieldLen))) } frame.Fields = append(frame.Fields, data.NewField("State", nil, make([]string, fieldLen))) + frame.Fields = append(frame.Fields, data.NewField("Info", nil, make([]string, fieldLen))) for evalIdx, evalResult := range evalResults { for lIdx, v := range labelColumns { frame.Set(lIdx, evalIdx, evalResult.Instance[v]) } + frame.Set(len(labelColumns), evalIdx, evalResult.State.String()) + + switch { + case evalResult.Error != nil: + frame.Set(len(labelColumns)+1, evalIdx, evalResult.Error.Error()) + case evalResult.EvaluationString != "": + frame.Set(len(labelColumns)+1, evalIdx, evalResult.EvaluationString) + } } return *frame } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 0338ae1fa1e..15c9b56c25c 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -1597,6 +1597,13 @@ func TestEval(t *testing.T) { "typeInfo": { "frame": "string" } + }, + { + "name": "Info", + "type": "string", + "typeInfo": { + "frame": "string" + } } ] }, @@ -1604,6 +1611,9 @@ func TestEval(t *testing.T) { "values": [ [ "Alerting" + ], + [ + "" ] ] } @@ -1648,6 +1658,13 @@ func TestEval(t *testing.T) { "typeInfo": { "frame": "string" } + }, + { + "name": "Info", + "type": "string", + "typeInfo": { + "frame": "string" + } } ] }, @@ -1655,6 +1672,9 @@ func TestEval(t *testing.T) { "values": [ [ "Normal" + ], + [ + "" ] ] } diff --git a/public/app/features/alerting/unified/api/preview.ts b/public/app/features/alerting/unified/api/preview.ts new file mode 100644 index 00000000000..2d51276fced --- /dev/null +++ b/public/app/features/alerting/unified/api/preview.ts @@ -0,0 +1,83 @@ +import { + dataFrameFromJSON, + DataFrameJSON, + getDefaultTimeRange, + LoadingState, + PanelData, + withLoadingIndicator, +} from '@grafana/data'; +import { getBackendSrv, toDataQueryError } from '@grafana/runtime'; +import { Observable, of } from 'rxjs'; +import { catchError, map, share } from 'rxjs/operators'; +import { + CloudPreviewRuleRequest, + GrafanaPreviewRuleRequest, + isCloudPreviewRequest, + isGrafanaPreviewRequest, + PreviewRuleRequest, + PreviewRuleResponse, +} from '../types/preview'; +import { RuleFormType } from '../types/rule-form'; + +export function previewAlertRule(request: PreviewRuleRequest): Observable { + if (isCloudPreviewRequest(request)) { + return previewCloudAlertRule(request); + } + + if (isGrafanaPreviewRequest(request)) { + return previewGrafanaAlertRule(request); + } + + throw new Error('unsupported preview rule request'); +} + +type GrafanaPreviewRuleResponse = { + instances: DataFrameJSON[]; +}; + +function previewGrafanaAlertRule(request: GrafanaPreviewRuleRequest): Observable { + const type = RuleFormType.grafana; + + return withLoadingIndicator({ + whileLoading: createResponse(type), + source: getBackendSrv() + .fetch({ + method: 'POST', + url: `/api/v1/rule/test/grafana`, + data: request, + }) + .pipe( + map(({ data }) => { + return createResponse(type, { + state: LoadingState.Done, + series: data.instances.map(dataFrameFromJSON), + }); + }), + catchError((error: Error) => { + return of( + createResponse(type, { + state: LoadingState.Error, + error: toDataQueryError(error), + }) + ); + }), + share() + ), + }); +} + +function createResponse(ruleType: RuleFormType, data: Partial = {}): PreviewRuleResponse { + return { + ruleType, + data: { + state: LoadingState.Loading, + series: [], + timeRange: getDefaultTimeRange(), + ...data, + }, + }; +} + +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/rule-editor/ConditionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/ConditionsStep.tsx index d82d5940c98..e109164d4bc 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ConditionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ConditionsStep.tsx @@ -8,6 +8,7 @@ import { timeOptions, timeValidationPattern } from '../../utils/time'; import { ConditionField } from './ConditionField'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { RuleEditorSection } from './RuleEditorSection'; +import { PreviewRule } from './PreviewRule'; const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds @@ -142,6 +143,7 @@ export const ConditionsStep: FC = () => { )} + ); }; diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx new file mode 100644 index 00000000000..0cb1db7b1ec --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useState } from 'react'; +import { css } from '@emotion/css'; +import { useFormContext } from 'react-hook-form'; +import { takeWhile } from 'rxjs/operators'; +import { useMountedState } from 'react-use'; +import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui'; +import { dateTimeFormatISO, GrafanaTheme2, LoadingState } from '@grafana/data'; +import { RuleFormType } from '../../types/rule-form'; +import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview'; +import { previewAlertRule } from '../../api/preview'; +import { PreviewRuleResult } from './PreviewRuleResult'; + +const fields: string[] = ['type', 'dataSourceName', 'condition', 'queries', 'expression']; + +export function PreviewRule(): React.ReactElement | null { + const styles = useStyles2(getStyles); + const [preview, onPreview] = usePreview(); + const { getValues } = useFormContext(); + const [type] = getValues(fields); + + if (type === RuleFormType.cloud) { + return null; + } + + return ( +
+ + + + +
+ ); +} + +function usePreview(): [PreviewRuleResponse | undefined, () => void] { + const [preview, setPreview] = useState(); + const { getValues } = useFormContext(); + const isMounted = useMountedState(); + + const onPreview = useCallback(() => { + const values = getValues(fields); + const request = createPreviewRequest(values); + + previewAlertRule(request) + .pipe(takeWhile((response) => !isCompleted(response), true)) + .subscribe((response) => { + if (!isMounted()) { + return; + } + setPreview(response); + }); + }, [getValues, isMounted]); + + return [preview, onPreview]; +} + +function createPreviewRequest(values: any[]): PreviewRuleRequest { + const [type, dataSourceName, condition, queries, expression] = values; + + switch (type) { + case RuleFormType.cloud: + return { + dataSourceName, + expr: expression, + }; + + case RuleFormType.grafana: + return { + grafana_condition: { + condition, + data: queries, + now: dateTimeFormatISO(Date.now()), + }, + }; + + default: + throw new Error(`Alert type ${type} not supported by preview.`); + } +} + +function isCompleted(response: PreviewRuleResponse): boolean { + switch (response.data.state) { + case LoadingState.Done: + case LoadingState.Error: + return true; + default: + return false; + } +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css` + margin-top: ${theme.spacing(2)}; + `, + }; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRuleResult.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRuleResult.tsx new file mode 100644 index 00000000000..d50eb5414e8 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRuleResult.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { useStyles2 } from '@grafana/ui'; +import { PanelRenderer } from '@grafana/runtime'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { PreviewRuleResponse } from '../../types/preview'; +import { RuleFormType } from '../../types/rule-form'; + +type Props = { + preview: PreviewRuleResponse | undefined; +}; + +export function PreviewRuleResult(props: Props): React.ReactElement | null { + const { preview } = props; + const styles = useStyles2(getStyles); + + if (!preview) { + return null; + } + + const { data, ruleType } = preview; + + if (data.state === LoadingState.Loading) { + return ( +
+ Loading preview... +
+ ); + } + + if (data.state === LoadingState.Error) { + return
{data.error ?? 'Failed to preview alert rule'}
; + } + + return ( +
+ + Preview based on the result of running the query, for this moment.{' '} + {ruleType === RuleFormType.grafana ? 'Configuration for `no data` and `error handling` is not applied.' : null} + +
+ + {({ width, height }) => ( +
+ +
+ )} +
+
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css` + margin: ${theme.spacing(2)} 0; + `, + table: css` + flex: 1 1 auto; + height: 135px; + margin-top: ${theme.spacing(2)}; + border: 1px solid ${theme.colors.border.medium}; + border-radius: ${theme.shape.borderRadius(1)}; + `, + }; +} diff --git a/public/app/features/alerting/unified/state/AlertingQueryRunner.ts b/public/app/features/alerting/unified/state/AlertingQueryRunner.ts index c69bffd41b0..8bf5daaccc2 100644 --- a/public/app/features/alerting/unified/state/AlertingQueryRunner.ts +++ b/public/app/features/alerting/unified/state/AlertingQueryRunner.ts @@ -1,5 +1,5 @@ -import { merge, Observable, of, OperatorFunction, ReplaySubject, timer, Unsubscribable } from 'rxjs'; -import { catchError, map, mapTo, share, takeUntil } from 'rxjs/operators'; +import { Observable, of, OperatorFunction, ReplaySubject, Unsubscribable } from 'rxjs'; +import { catchError, map, share } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; import { dataFrameFromJSON, @@ -9,6 +9,7 @@ import { PanelData, rangeUtil, TimeRange, + withLoadingIndicator, } from '@grafana/data'; import { FetchResponse, toDataQueryError } from '@grafana/runtime'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; @@ -107,14 +108,15 @@ const runRequest = (backendSrv: BackendSrv, queries: GrafanaQuery[]): Observable requestId: uuidv4(), }; - const runningRequest = backendSrv.fetch(request).pipe( - mapToPanelData(initial), - catchError((error) => of(mapErrorToPanelData(initial, error))), - cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), - share() - ); - - return merge(timer(200).pipe(mapTo(initial), takeUntil(runningRequest)), runningRequest); + return withLoadingIndicator({ + whileLoading: initial, + source: backendSrv.fetch(request).pipe( + mapToPanelData(initial), + catchError((error) => of(mapErrorToPanelData(initial, error))), + cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), + share() + ), + }); }; const initialState = (queries: GrafanaQuery[], state: LoadingState): Record => { diff --git a/public/app/features/alerting/unified/types/preview.ts b/public/app/features/alerting/unified/types/preview.ts new file mode 100644 index 00000000000..c46fea30851 --- /dev/null +++ b/public/app/features/alerting/unified/types/preview.ts @@ -0,0 +1,31 @@ +import { PanelData } from '@grafana/data'; +import { GrafanaQuery } from 'app/types/unified-alerting-dto'; +import { RuleFormType } from './rule-form'; + +export type PreviewRuleRequest = GrafanaPreviewRuleRequest | CloudPreviewRuleRequest; + +export type GrafanaPreviewRuleRequest = { + grafana_condition: { + condition: string; + data: GrafanaQuery[]; + now: string; + }; +}; + +export type CloudPreviewRuleRequest = { + dataSourceName: string; + expr: string; +}; + +export type PreviewRuleResponse = { + ruleType: RuleFormType; + data: PanelData; +}; + +export function isCloudPreviewRequest(request: PreviewRuleRequest): request is CloudPreviewRuleRequest { + return 'expr' in request; +} + +export function isGrafanaPreviewRequest(request: PreviewRuleRequest): request is GrafanaPreviewRuleRequest { + return 'grafana_condition' in request; +}