Alerting: allow creating/editing recording rules for Loki and Cortex (#38064)

This commit is contained in:
Domas 2021-08-24 11:31:56 +03:00 committed by GitHub
parent 309d263531
commit 9d8f61c738
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 255 additions and 42 deletions

View File

@ -1,6 +1,6 @@
import { Matcher, render, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { BackendSrv, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import RuleEditor from './RuleEditor';
import { Route, Router } from 'react-router-dom';
@ -19,6 +19,7 @@ import { DashboardSearchHit } from 'app/features/search/types';
import { getDefaultQueries } from './utils/rule-form';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import * as api from 'app/features/manage-dashboards/state/actions';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
@ -64,7 +65,7 @@ function renderRuleEditor(identifier?: string) {
const ui = {
inputs: {
name: byLabelText('Alert name'),
name: byLabelText('Rule name'),
alertType: byTestId('alert-type-picker'),
dataSource: byTestId('datasource-picker'),
folder: byTestId('folder-picker'),
@ -228,6 +229,169 @@ describe('RuleEditor', () => {
});
});
it('can create a new cloud recording rule', async () => {
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.api.fetchRulerRulesGroup.mockResolvedValue({
name: 'group2',
rules: [],
});
mocks.api.fetchRulerRules.mockResolvedValue({
namespace1: [
{
name: 'group1',
rules: [],
},
],
namespace2: [
{
name: 'group2',
rules: [],
},
],
});
await renderRuleEditor();
await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
await clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed recording rule/);
const dataSourceSelect = ui.inputs.dataSource.get();
userEvent.click(byRole('textbox').get(dataSourceSelect));
await clickSelectOption(dataSourceSelect, 'Prom (default)');
await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
await clickSelectOption(ui.inputs.group.get(), 'group2');
await userEvent.type(ui.inputs.expr.get(), 'up == 1');
userEvent.click(ui.buttons.addLabel.get());
await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
// save and check what was sent to backend
userEvent.click(ui.buttons.save.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', {
name: 'group2',
rules: [
{
record: 'my great new recording rule',
labels: { team: 'the a-team' },
expr: 'up == 1',
},
],
});
});
it('can edit grafana managed rule', async () => {
const uid = 'FOOBAR123';
const folder = {
title: 'Folder A',
uid: 'abcd',
id: 1,
};
jest.spyOn(api, 'searchFolders').mockResolvedValue([folder] as DashboardSearchHit[]);
const getFolderByUid = jest.fn().mockResolvedValue({
...folder,
canSave: true,
});
const dataSources = {
default: mockDataSource({
type: 'prometheus',
name: 'Prom',
isDefault: true,
}),
};
const backendSrv = ({
getFolderByUid,
} as any) as BackendSrv;
setBackendSrv(backendSrv);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.api.fetchRulerRules.mockResolvedValue({
[folder.title]: [
{
interval: '1m',
name: 'my great new rule',
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
grafana_alert: {
uid,
namespace_uid: 'abcd',
namespace_id: 1,
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Alerting,
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'my great new rule',
},
},
],
},
],
});
await renderRuleEditor(uid);
// check that it's filled in
const nameInput = await ui.inputs.name.find();
expect(nameInput).toHaveValue('my great new rule');
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description');
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary');
// add an annotation
await clickSelectOption(ui.inputs.annotationKey(2).get(), /Add new/);
await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom');
await userEvent.type(ui.inputs.annotationValue(2).get(), 'value');
//add a label
await userEvent.type(ui.inputs.labelKey(2).get(), 'custom');
await userEvent.type(ui.inputs.labelValue(2).get(), 'value');
// save and check what was sent to backend
userEvent.click(ui.buttons.save.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', {
interval: '1m',
name: 'my great new rule',
rules: [
{
annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
labels: { severity: 'warn', team: 'the a-team', custom: 'value' },
for: '5m',
grafana_alert: {
uid,
condition: 'B',
data: getDefaultQueries(),
exec_err_state: 'Alerting',
no_data_state: 'NoData',
title: 'my great new rule',
},
},
],
});
});
it('for cloud alerts, should only allow to select editable rules sources', async () => {
const dataSources: Record<string, DataSourceInstanceSettings<any>> = {
// can edit rules

View File

@ -121,7 +121,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
{showStep2 && (
<>
<QueryStep />
{type === RuleFormType.cloud ? <CloudConditionsStep /> : <GrafanaConditionsStep />}
{type === RuleFormType.grafana ? <GrafanaConditionsStep /> : <CloudConditionsStep />}
<DetailsStep />
</>
)}

View File

@ -40,19 +40,24 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
if (contextSrv.isEditor) {
result.push({
label: 'Cortex/Loki managed alert',
value: RuleFormType.cloud,
value: RuleFormType.cloudAlerting,
description: 'Alert based on a system or application behavior. Based on Prometheus.',
});
result.push({
label: 'Cortex/Loki managed recording rule',
value: RuleFormType.cloudRecording,
description: 'Recording rule to pre-compute frequently needed or expensive calculations. Based on Prometheus.',
});
}
return result;
}, []);
return (
<RuleEditorSection stepNo={1} title="Alert type">
<RuleEditorSection stepNo={1} title="Rule type">
<Field
className={styles.formInput}
label="Alert name"
label="Rule name"
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
@ -65,7 +70,7 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
<div className={styles.flexRow}>
<Field
disabled={editingExistingRule}
label="Alert type"
label="Rule type"
className={styles.formInput}
error={errors.type?.message}
invalid={!!errors.type?.message}
@ -87,7 +92,7 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
}}
/>
</Field>
{ruleFormType === RuleFormType.cloud && (
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && (
<Field
className={styles.formInput}
label="Select data source"
@ -115,9 +120,9 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
</Field>
)}
</div>
{ruleFormType === RuleFormType.cloud && dataSourceName && (
<GroupAndNamespaceFields dataSourceName={dataSourceName} />
)}
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
dataSourceName && <GroupAndNamespaceFields dataSourceName={dataSourceName} />}
{ruleFormType === RuleFormType.grafana && (
<Field
label="Folder"

View File

@ -3,7 +3,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { Field, Input, InputControl, Select, useStyles } from '@grafana/ui';
import { useFormContext } from 'react-hook-form';
import { RuleFormValues } from '../../types/rule-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { timeOptions } from '../../utils/time';
import { RuleEditorSection } from './RuleEditorSection';
import { PreviewRule } from './PreviewRule';
@ -13,9 +13,17 @@ export const CloudConditionsStep: FC = () => {
const {
register,
control,
watch,
formState: { errors },
} = useFormContext<RuleFormValues>();
const type = watch('type');
// cloud recording rules do not have alert conditions
if (type === RuleFormType.cloudRecording) {
return null;
}
return (
<RuleEditorSection stepNo={3} title="Define alert conditions">
<Field label="For" description="Expression has to be true for this long for the alert to be fired.">

View File

@ -2,15 +2,27 @@ import React, { FC } from 'react';
import LabelsField from './LabelsField';
import AnnotationsField from './AnnotationsField';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
export const DetailsStep: FC = () => {
const { watch } = useFormContext<RuleFormValues>();
const type = watch('type');
return (
<RuleEditorSection
stepNo={4}
title="Add details for your alert"
description="Write a summary and add labels to help you better manage your alerts"
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
title={
type === RuleFormType.cloudRecording ? 'Add details for your recording rule' : 'Add details for your alert'
}
description={
type === RuleFormType.cloudRecording
? 'Add labels to help you better manage your rules'
: 'Write a summary and add labels to help you better manage your alerts'
}
>
<AnnotationsField />
{type !== RuleFormType.cloudRecording && <AnnotationsField />}
<LabelsField />
</RuleEditorSection>
);

View File

@ -18,7 +18,7 @@ export function PreviewRule(): React.ReactElement | null {
const { getValues } = useFormContext();
const [type] = getValues(fields);
if (type === RuleFormType.cloud) {
if (type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) {
return null;
}
@ -60,7 +60,7 @@ function createPreviewRequest(values: any[]): PreviewRuleRequest {
const [type, dataSourceName, condition, queries, expression] = values;
switch (type) {
case RuleFormType.cloud:
case RuleFormType.cloudAlerting:
return {
dataSourceName,
expr: expression,

View File

@ -15,8 +15,11 @@ export const QueryStep: FC = () => {
const type = watch('type');
const dataSourceName = watch('dataSourceName');
return (
<RuleEditorSection stepNo={2} title="Create a query to be alerted on">
{type === RuleFormType.cloud && dataSourceName && (
<RuleEditorSection
stepNo={2}
title={type === RuleFormType.cloudRecording ? 'Create a query to be recorded' : 'Create a query to be alerted on'}
>
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"

View File

@ -63,7 +63,7 @@ export function RuleListErrors(): ReactElement {
return (
<>
{errors.length && !closed && (
{!!errors.length && !closed && (
<Alert
data-testid="cloud-rulessource-errors"
title="Errors loading rules"

View File

@ -6,6 +6,7 @@ import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
import { useDispatch } from 'react-redux';
import { useEffect } from 'react';
import { checkIfLotexSupportsEditingRulesAction } from '../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
interface ResultBag {
isEditable?: boolean;
@ -20,7 +21,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
const { folder, loading } = useFolder(folderUID);
useEffect(() => {
if (checkEditingRequests[rulesSourceName] === undefined) {
if (checkEditingRequests[rulesSourceName] === undefined && rulesSourceName !== GRAFANA_RULES_SOURCE_NAME) {
dispatch(checkIfLotexSupportsEditingRulesAction(rulesSourceName));
}
}, [rulesSourceName, checkEditingRequests, dispatch]);

View File

@ -1,4 +1,4 @@
import { locationService } from '@grafana/runtime';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { createAsyncThunk } from '@reduxjs/toolkit';
import {
AlertmanagerAlert,
@ -41,7 +41,7 @@ import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
import { makeAMLink, retryWhile } from '../utils/misc';
import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
import {
isCloudRuleIdentifier,
isGrafanaRuleIdentifier,
@ -50,7 +50,6 @@ import {
isRulerNotSupportedResponse,
} from '../utils/rules';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
import { backendSrv } from 'app/core/services/backend_srv';
import * as ruleId from '../utils/rule-id';
import { isEmpty } from 'lodash';
import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError';
@ -251,7 +250,7 @@ export function deleteRuleAction(
async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> {
const { dataSourceName, group, namespace } = values;
const formRule = formValuesToRulerAlertingRuleDTO(values);
const formRule = formValuesToRulerRuleDTO(values);
if (dataSourceName && group && namespace) {
// if we're updating a rule...
if (existing) {
@ -373,7 +372,7 @@ export const saveRuleFormAction = createAsyncThunk(
const { type } = values;
// in case of system (cortex/loki)
let identifier: RuleIdentifier;
if (type === RuleFormType.cloud) {
if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) {
identifier = await saveLotexRule(values, existing);
// in case of grafana managed
} else if (type === RuleFormType.grafana) {
@ -545,7 +544,7 @@ export const deleteTemplateAction = (templateName: string, alertManagerSourceNam
export const fetchFolderAction = createAsyncThunk(
'unifiedalerting/fetchFolder',
(uid: string): Promise<FolderDTO> => withSerializedError(backendSrv.getFolderByUid(uid))
(uid: string): Promise<FolderDTO> => withSerializedError((getBackendSrv() as any).getFolderByUid(uid))
);
export const fetchFolderIfNotFetchedAction = (uid: string): ThunkResult<void> => {

View File

@ -2,7 +2,8 @@ import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alertin
export enum RuleFormType {
grafana = 'grafana',
cloud = 'cloud',
cloudAlerting = 'cloud-alerting',
cloudRecording = 'cloud-recording',
}
export interface RuleFormValues {

View File

@ -20,14 +20,14 @@ import {
AlertQuery,
Labels,
PostableRuleGrafanaRuleDTO,
RulerAlertingRuleDTO,
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { Annotation } from './constants';
import { isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import { parseInterval } from './time';
export const getDefaultFormValues = (): RuleFormValues =>
@ -59,15 +59,24 @@ export const getDefaultFormValues = (): RuleFormValues =>
forTimeUnit: 'm',
});
export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerAlertingRuleDTO {
const { name, expression, forTime, forTimeUnit } = values;
return {
alert: name,
for: `${forTime}${forTimeUnit}`,
annotations: arrayToRecord(values.annotations || []),
labels: arrayToRecord(values.labels || []),
expr: expression,
};
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
const { name, expression, forTime, forTimeUnit, type } = values;
if (type === RuleFormType.cloudAlerting) {
return {
alert: name,
for: `${forTime}${forTimeUnit}`,
annotations: arrayToRecord(values.annotations || []),
labels: arrayToRecord(values.labels || []),
expr: expression,
};
} else if (type === RuleFormType.cloudRecording) {
return {
record: name,
labels: arrayToRecord(values.labels || []),
expr: expression,
};
}
throw new Error(`unexpected rule type: ${type}`);
}
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
@ -125,7 +134,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
return {
...defaultFormValues,
name: rule.alert,
type: RuleFormType.cloud,
type: RuleFormType.cloudAlerting,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
@ -135,8 +144,19 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
annotations: listifyLabelsOrAnnotations(rule.annotations),
labels: listifyLabelsOrAnnotations(rule.labels),
};
} else if (isRecordingRulerRule(rule)) {
return {
...defaultFormValues,
name: rule.record,
type: RuleFormType.cloudRecording,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
expression: rule.expr,
labels: listifyLabelsOrAnnotations(rule.labels),
};
} else {
throw new Error('Editing recording rules not supported (yet)');
throw new Error('Unexpected type of rule for cloud rules source');
}
}
}