Alerting: Add alert preview to cloud rules editor (#54950)

This commit is contained in:
Konrad Lalik 2022-09-23 10:05:08 +02:00 committed by GitHub
parent e8a60c1988
commit 7114c51f9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 440 additions and 45 deletions

View File

@ -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<PreviewRuleResponse> {
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<PreviewRuleResponse> {
const type = RuleFormType.grafana;
function fetchAlertRulePreview(
request: PreviewRuleRequest,
dataSourceUid: string,
ruleType: RuleFormType
): Observable<PreviewRuleResponse> {
return withLoadingIndicator({
whileLoading: createResponse(type),
whileLoading: createResponse(ruleType),
source: getBackendSrv()
.fetch<GrafanaPreviewRuleResponse>({
.fetch<AlertRulePreviewResponse>({
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<PanelData> = {}):
},
};
}
function previewCloudAlertRule(request: CloudPreviewRuleRequest): Observable<PreviewRuleResponse> {
throw new Error('preview for cloud alerting rules is not implemented');
}

View File

@ -93,7 +93,7 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd
<Card.Meta>{url}</Card.Meta>
<Card.Actions>
<LinkButton href={makeDataSourceLink(dataSource)} size="sm" variant="secondary">
Go to datasouce
Go to datasource
</LinkButton>
</Card.Actions>
</Card>

View File

@ -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 (
<table className={styles.table}>
<caption>
<div>Alerts preview</div>
<span>Preview based on the result of running the query for this moment.</span>
</caption>
<thead>
<tr>
<th>State</th>
<th>Labels</th>
<th>Info</th>
</tr>
</thead>
<tbody>
{alertPreview.instances.map(({ state, info, labels }, index) => {
const instanceTags = labelsToTags(labels);
return (
<tr key={index}>
<td>{<AlertStateTag state={state} />}</td>
<td>
<TagList tags={instanceTags} className={styles.tagList} />
</td>
<td>
{info && (
<Tooltip content={info}>
<Icon name="info-circle" />
</Tooltip>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
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;
`,
});

View File

@ -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<ExpressionEditorProps> = ({ 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<ExpressionEditorProps> = ({ 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<ExpressionEditorProps> = ({ value, onChange, d
return <div>Could not load query editor due to: {errorMessage}</div>;
}
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 (
<QueryEditor
query={query}
queries={[query]}
app={CoreApp.CloudAlerting}
onChange={onChangeQuery}
onRunQuery={noop}
datasource={dataSource}
/>
<>
<QueryEditor
query={dataQuery}
queries={[dataQuery]}
app={CoreApp.CloudAlerting}
onChange={onChangeQuery}
onRunQuery={noop}
datasource={dataSource}
/>
<div className={styles.preview}>
<Button type="button" onClick={onRunQueriesClick} disabled={alertPreview?.data.state === LoadingState.Loading}>
Preview alerts
</Button>
{previewLoaded && !previewHasAlerts && (
<Alert title="Alerts preview" severity="info" className={styles.previewAlert}>
There are no firing alerts for your query.
</Alert>
)}
{previewHasAlerts && <CloudAlertPreview preview={previewDataFrame} />}
</div>
</>
);
};
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<T extends DataQuery = DataQuery> = {
mapToValue: (query: T) => string;
mapToQuery: (existing: T, value: string | undefined) => T;

View File

@ -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<PreviewRuleResponse | undefined>();
const { getValues } = useFormContext<RuleFormValues>();
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,
};

View File

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

View File

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

View File

@ -14,8 +14,7 @@ export const Query: FC = () => {
formState: { errors },
} = useFormContext<RuleFormValues>();
const type = watch('type');
const dataSourceName = watch('dataSourceName');
const [type, dataSourceName] = watch(['type', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana;
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;

View File

@ -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<Props> = ({ state }) => (

View File

@ -14,6 +14,7 @@ export type GrafanaPreviewRuleRequest = {
};
export type CloudPreviewRuleRequest = {
dataSourceUid: string;
dataSourceName: string;
expr: string;
};

View File

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

View File

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

View File

@ -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(