Alerting: added possibility to preview grafana managed alert rules. (#34600)

* starting to add eval logic.

* wip

* first version of test rule.

* reverted file.

* add info colum to result to show error or (with CC evalmatches)

* fix labels in evalmatch

* fix be test

* refactored using observables.

* moved widht/height div to outside panel rendere.

* adding docs api level.

* adding container styles to error div.

* increasing size of preview.

Co-authored-by: kyle <kyle@grafana.com>
This commit is contained in:
Marcus Andersson 2021-05-26 10:06:28 +02:00 committed by GitHub
parent 42f33630c7
commit e19b3df1a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 362 additions and 12 deletions

View File

@ -20,3 +20,4 @@ export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks';
export { DocsId } from './docs'; export { DocsId } from './docs';
export { makeClassES5Compatible } from './makeClassES5Compatible'; export { makeClassES5Compatible } from './makeClassES5Compatible';
export { anyToNumber } from './anyToNumber'; export { anyToNumber } from './anyToNumber';
export { withLoadingIndicator, WithLoadingIndicatorOptions } from './withLoadingIndicator';

View File

@ -0,0 +1,17 @@
import { merge, Observable, timer } from 'rxjs';
import { mapTo, takeUntil } from 'rxjs/operators';
/**
* @internal
*/
export type WithLoadingIndicatorOptions<T> = {
whileLoading: T;
source: Observable<T>;
};
/**
* @internal
*/
export function withLoadingIndicator<T>({ whileLoading, source }: WithLoadingIndicatorOptions<T>): Observable<T> {
return merge(timer(200).pipe(mapTo(whileLoading), takeUntil(source)), source);
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/expr/mathexp" "github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
ptr "github.com/xorcare/pointer" ptr "github.com/xorcare/pointer"
@ -202,7 +203,7 @@ func TestConditionsCmdExecute(t *testing.T) {
vars: mathexp.Vars{ vars: mathexp.Vars{
"A": mathexp.Results{ "A": mathexp.Results{
Values: []mathexp.Value{ 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)), valBasedSeries(ptr.Float64(0), ptr.Float64(10)),
}, },
}, },
@ -218,7 +219,7 @@ func TestConditionsCmdExecute(t *testing.T) {
}}, }},
resultNumber: func() mathexp.Number { resultNumber: func() mathexp.Number {
v := valBasedNumber(ptr.Float64(1)) 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 return v
}, },
}, },

View File

@ -24,6 +24,11 @@ func (cr classicReducer) ValidReduceFunc() bool {
//nolint: gocyclo //nolint: gocyclo
func (cr classicReducer) Reduce(series mathexp.Series) mathexp.Number { func (cr classicReducer) Reduce(series mathexp.Series) mathexp.Number {
num := mathexp.NewNumber("", nil) num := mathexp.NewNumber("", nil)
if series.GetLabels() != nil {
num.SetLabels(series.GetLabels().Copy())
}
num.SetValue(nil) num.SetValue(nil)
if series.Len() == 0 { if series.Len() == 0 {

View File

@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/expr/mathexp" "github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
ptr "github.com/xorcare/pointer" ptr "github.com/xorcare/pointer"
@ -408,6 +409,17 @@ func valBasedSeries(vals ...*float64) mathexp.Series {
return newSeries 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 { func unixTimePointer(sec int64) *time.Time {
t := time.Unix(sec, 0) t := time.Unix(sec, 0)
return &t return &t

View File

@ -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(lKey, nil, make([]string, fieldLen)))
} }
frame.Fields = append(frame.Fields, data.NewField("State", 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 evalIdx, evalResult := range evalResults {
for lIdx, v := range labelColumns { for lIdx, v := range labelColumns {
frame.Set(lIdx, evalIdx, evalResult.Instance[v]) frame.Set(lIdx, evalIdx, evalResult.Instance[v])
} }
frame.Set(len(labelColumns), evalIdx, evalResult.State.String()) 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 return *frame
} }

View File

@ -1597,6 +1597,13 @@ func TestEval(t *testing.T) {
"typeInfo": { "typeInfo": {
"frame": "string" "frame": "string"
} }
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
} }
] ]
}, },
@ -1604,6 +1611,9 @@ func TestEval(t *testing.T) {
"values": [ "values": [
[ [
"Alerting" "Alerting"
],
[
""
] ]
] ]
} }
@ -1648,6 +1658,13 @@ func TestEval(t *testing.T) {
"typeInfo": { "typeInfo": {
"frame": "string" "frame": "string"
} }
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
} }
] ]
}, },
@ -1655,6 +1672,9 @@ func TestEval(t *testing.T) {
"values": [ "values": [
[ [
"Normal" "Normal"
],
[
""
] ]
] ]
} }

View File

@ -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<PreviewRuleResponse> {
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<PreviewRuleResponse> {
const type = RuleFormType.grafana;
return withLoadingIndicator({
whileLoading: createResponse(type),
source: getBackendSrv()
.fetch<GrafanaPreviewRuleResponse>({
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<PanelData> = {}): PreviewRuleResponse {
return {
ruleType,
data: {
state: LoadingState.Loading,
series: [],
timeRange: getDefaultTimeRange(),
...data,
},
};
}
function previewCloudAlertRule(request: CloudPreviewRuleRequest): Observable<PreviewRuleResponse> {
throw new Error('preview for cloud alerting rules is not implemented');
}

View File

@ -8,6 +8,7 @@ import { timeOptions, timeValidationPattern } from '../../utils/time';
import { ConditionField } from './ConditionField'; import { ConditionField } from './ConditionField';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
import { PreviewRule } from './PreviewRule';
const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -142,6 +143,7 @@ export const ConditionsStep: FC = () => {
</Field> </Field>
</> </>
)} )}
<PreviewRule />
</RuleEditorSection> </RuleEditorSection>
); );
}; };

View File

@ -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 (
<div className={styles.container}>
<HorizontalGroup>
<Button type="button" variant="primary" onClick={onPreview}>
Preview your alert
</Button>
</HorizontalGroup>
<PreviewRuleResult preview={preview} />
</div>
);
}
function usePreview(): [PreviewRuleResponse | undefined, () => void] {
const [preview, setPreview] = useState<PreviewRuleResponse | undefined>();
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)};
`,
};
}

View File

@ -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 (
<div className={styles.container}>
<span>Loading preview...</span>
</div>
);
}
if (data.state === LoadingState.Error) {
return <div className={styles.container}>{data.error ?? 'Failed to preview alert rule'}</div>;
}
return (
<div className={styles.container}>
<span>
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}
</span>
<div className={styles.table}>
<AutoSizer>
{({ width, height }) => (
<div style={{ width: `${width}px`, height: `${height}px` }}>
<PanelRenderer title="" width={width} height={height} pluginId="table" data={data} />
</div>
)}
</AutoSizer>
</div>
</div>
);
}
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)};
`,
};
}

View File

@ -1,5 +1,5 @@
import { merge, Observable, of, OperatorFunction, ReplaySubject, timer, Unsubscribable } from 'rxjs'; import { Observable, of, OperatorFunction, ReplaySubject, Unsubscribable } from 'rxjs';
import { catchError, map, mapTo, share, takeUntil } from 'rxjs/operators'; import { catchError, map, share } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
dataFrameFromJSON, dataFrameFromJSON,
@ -9,6 +9,7 @@ import {
PanelData, PanelData,
rangeUtil, rangeUtil,
TimeRange, TimeRange,
withLoadingIndicator,
} from '@grafana/data'; } from '@grafana/data';
import { FetchResponse, toDataQueryError } from '@grafana/runtime'; import { FetchResponse, toDataQueryError } from '@grafana/runtime';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
@ -107,14 +108,15 @@ const runRequest = (backendSrv: BackendSrv, queries: GrafanaQuery[]): Observable
requestId: uuidv4(), requestId: uuidv4(),
}; };
const runningRequest = backendSrv.fetch<AlertingQueryResponse>(request).pipe( return withLoadingIndicator({
mapToPanelData(initial), whileLoading: initial,
catchError((error) => of(mapErrorToPanelData(initial, error))), source: backendSrv.fetch<AlertingQueryResponse>(request).pipe(
cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId), mapToPanelData(initial),
share() catchError((error) => of(mapErrorToPanelData(initial, error))),
); cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId),
share()
return merge(timer(200).pipe(mapTo(initial), takeUntil(runningRequest)), runningRequest); ),
});
}; };
const initialState = (queries: GrafanaQuery[], state: LoadingState): Record<string, PanelData> => { const initialState = (queries: GrafanaQuery[], state: LoadingState): Record<string, PanelData> => {

View File

@ -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;
}