Alerting: Implement UI for grafana-managed recording rules (#90360)

* Implement UI for grafana-managed recording rules

* use undefined for the duration instead of null , for recording rules

* Fix tests

* add tests

* Add pause functionality for grafana recording rules

* update translations

* remove obsolete snapshot

* use createUrl instead of renderUrl

* refactor

* Add validation for grafana recording rule name

* create util functions for rule types and add record field in mock function

* add util isDatatSourceManagedRuleByType

* refactor

* Add metric field in alert rule form

* fix alert name component

* update width for alert name and metric

* fix test

* add validation back to cloud recording rules name

* Alerting: Recording rules PR review (#90654)

Update type helper methods

* add slash in createUrl

* fix baseurl in the returnTo

* nits

* Add metric on expanded row in the alert list view

* nits
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>

* update snapshot

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar 2024-07-26 13:52:22 +02:00 committed by GitHub
parent c0af387766
commit 8423d06988
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 647 additions and 209 deletions

View File

@ -2280,9 +2280,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/alerting/unified/components/rules/GrafanaRules.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@ -2328,10 +2326,7 @@ exports[`better eslint`] = {
],
"public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]

View File

@ -25,6 +25,7 @@ export const LogMessages = {
cancelSavingAlertRule: 'user canceled alert rule creation',
successSavingAlertRule: 'alert rule saved successfully',
unknownMessageFromError: 'unknown messageFromError',
grafanaRecording: 'creating Grafana recording rule from scratch',
loadedCentralAlertStateHistory: 'loaded central alert state history',
};

View File

@ -17,7 +17,10 @@ import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id';
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string; type?: 'recording' | 'alerting' }>;
type RuleEditorProps = GrafanaRouteComponentProps<{
id?: string;
type?: 'recording' | 'alerting' | 'grafana-recording';
}>;
const defaultPageNav: Partial<NavModelItem> = {
icon: 'bell',
@ -25,8 +28,8 @@ const defaultPageNav: Partial<NavModelItem> = {
};
// sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL
const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting') => {
if (type === 'recording') {
const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting' | 'grafana-recording') => {
if (type === 'recording' || type === 'grafana-recording') {
if (identifier) {
// this branch should never trigger actually, the type param isn't used when editing rules
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' };

View File

@ -112,6 +112,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "C",
},
],
"type": "grafana",
"type": "grafana-alerting",
}
`;

View File

@ -1,19 +1,25 @@
import { useFormContext } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Field, Input, Text } from '@grafana/ui';
import { Field, Input, Stack, Text } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { isCloudRecordingRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules';
import { RuleEditorSection } from './RuleEditorSection';
const recordingRuleNameValidationPattern = {
message:
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
const recordingRuleNameValidationPattern = (type: RuleFormType) => ({
message: isGrafanaRecordingRuleByType(type)
? 'Recording rule metric must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.'
: 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
};
});
export const AlertRuleNameInput = () => {
/**
* This component renders the input for the alert rule name.
* In case of recording rule, it also renders the input for the recording rule metric, and it validates this value.
*/
export const AlertRuleNameAndMetric = () => {
const {
register,
watch,
@ -21,8 +27,14 @@ export const AlertRuleNameInput = () => {
} = useFormContext<RuleFormValues>();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
if (!ruleFormType) {
return null;
}
const isRecording = isRecordingRuleByType(ruleFormType);
const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(ruleFormType);
const isCloudRecordingRule = isCloudRecordingRuleByType(ruleFormType);
const recordingLabel = isGrafanaRecordingRule ? 'recording rule and metric' : 'recording rule';
const entityName = isRecording ? recordingLabel : 'alert rule';
return (
<RuleEditorSection
stepNo={1}
@ -33,19 +45,37 @@ export const AlertRuleNameInput = () => {
</Text>
}
>
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input
data-testid={selectors.components.AlertRules.ruleNameField}
id="name"
width={35}
{...register('name', {
required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
})}
aria-label="name"
placeholder={`Give your ${entityName} a name`}
/>
</Field>
<Stack direction="column">
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input
data-testid={selectors.components.AlertRules.ruleNameField}
id="name"
width={38}
{...register('name', {
required: { value: true, message: 'Must enter a name' },
pattern: isCloudRecordingRule
? recordingRuleNameValidationPattern(RuleFormType.cloudRecording)
: undefined,
})}
aria-label="name"
placeholder={`Give your ${entityName} a name`}
/>
</Field>
{isGrafanaRecordingRule && (
<Field label="Metric" error={errors?.metric?.message} invalid={!!errors.metric?.message}>
<Input
id="metric"
width={38}
{...register('metric', {
required: { value: true, message: 'Must enter a metric name' },
pattern: recordingRuleNameValidationPattern(RuleFormType.grafanaRecording),
})}
aria-label="metric"
placeholder={`Give your metric a name`}
/>
</Field>
)}
</Stack>
</RuleEditorSection>
);
};

View File

@ -5,6 +5,7 @@ import { Controller, RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { isGrafanaAlertingRuleByType } from 'app/features/alerting/unified/utils/rules';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics';
@ -290,6 +291,9 @@ export function GrafanaEvaluationBehavior({
const { watch, setValue } = useFormContext<RuleFormValues>();
const isPaused = watch('isPaused');
const type = watch('type');
const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type);
return (
// TODO remove "and alert condition" for recording rules
@ -300,7 +304,8 @@ export function GrafanaEvaluationBehavior({
evaluateEvery={evaluateEvery}
enableProvisionedGroups={enableProvisionedGroups}
/>
<ForInput evaluateEvery={evaluateEvery} />
{/* Show the pending period input only for Grafana alerting rules */}
{isGrafanaAlertingRule && <ForInput evaluateEvery={evaluateEvery} />}
{existing && (
<Field htmlFor="pause-alert-switch">
@ -327,44 +332,48 @@ export function GrafanaEvaluationBehavior({
</Field>
)}
</Stack>
<CollapseToggle
isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
text="Configure no data and error handling"
/>
{showErrorHandling && (
{isGrafanaAlertingRule && (
<>
<NeedHelpInfoForConfigureNoDataError />
<Field htmlFor="no-data-state-input" label="Alert state if no data or all values are null">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
{...field}
inputId="no-data-state-input"
width={42}
includeNoData={true}
includeError={false}
onChange={(value) => onChange(value?.value)}
<CollapseToggle
isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
text="Configure no data and error handling"
/>
{showErrorHandling && (
<>
<NeedHelpInfoForConfigureNoDataError />
<Field htmlFor="no-data-state-input" label="Alert state if no data or all values are null">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
{...field}
inputId="no-data-state-input"
width={42}
includeNoData={true}
includeError={false}
onChange={(value) => onChange(value?.value)}
/>
)}
name="noDataState"
/>
)}
name="noDataState"
/>
</Field>
<Field htmlFor="exec-err-state-input" label="Alert state if execution error or timeout">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
{...field}
inputId="exec-err-state-input"
width={42}
includeNoData={false}
includeError={true}
onChange={(value) => onChange(value?.value)}
</Field>
<Field htmlFor="exec-err-state-input" label="Alert state if execution error or timeout">
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
{...field}
inputId="exec-err-state-input"
width={42}
includeNoData={false}
includeError={true}
onChange={(value) => onChange(value?.value)}
/>
)}
name="execErrState"
/>
)}
name="execErrState"
/>
</Field>
</Field>
</>
)}
</>
)}
</RuleEditorSection>

View File

@ -10,6 +10,7 @@ import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { isRecordingRuleByType } from '../../utils/rules';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection';
@ -62,11 +63,14 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
}
setShowLabelsEditor(false);
}
if (!type) {
return null;
}
return (
<RuleEditorSection
stepNo={4}
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure labels and notifications'}
title={isRecordingRuleByType(type) ? 'Add labels' : 'Configure labels and notifications'}
description={
<Stack direction="row" gap={0.5} alignItems="center">
{type === RuleFormType.cloudRecording ? (

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useMountedState } from 'react-use';
import { takeWhile } from 'rxjs/operators';
@ -13,6 +13,7 @@ import { previewAlertRule } from '../../api/preview';
import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus';
import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { isDataSourceManagedRuleByType } from '../../utils/rules';
import { PreviewRuleResult } from './PreviewRuleResult';
@ -25,7 +26,7 @@ export function PreviewRule(): React.ReactElement | null {
const [type, condition, queries] = watch(['type', 'condition', 'queries']);
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
if (type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) {
if (!type || isDataSourceManagedRuleByType(type)) {
return null;
}

View File

@ -14,8 +14,10 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import {
getRuleGroupLocationFromRuleWithLocation,
isGrafanaManagedRuleByType,
isGrafanaRulerRule,
isGrafanaRulerRulePaused,
isRecordingRuleByType,
} from 'app/features/alerting/unified/utils/rules';
import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
@ -23,8 +25,8 @@ import { RuleWithLocation } from 'app/types/unified-alerting';
import {
LogMessages,
logInfo,
trackAlertRuleFormError,
trackAlertRuleFormCancelled,
trackAlertRuleFormError,
trackAlertRuleFormSaved,
} from '../../../Analytics';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
@ -33,8 +35,8 @@ import { saveRuleFormAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { initialAsyncRequestState } from '../../../utils/redux';
import {
MANUAL_ROUTING_KEY,
DEFAULT_GROUP_EVALUATION_INTERVAL,
MANUAL_ROUTING_KEY,
formValuesFromExistingRule,
getDefaultFormValues,
getDefaultQueries,
@ -42,7 +44,7 @@ import {
normalizeDefaultAnnotations,
} from '../../../utils/rule-form';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameInput } from '../AlertRuleNameInput';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
@ -106,7 +108,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const type = watch('type');
const dataSourceName = watch('dataSourceName');
const showDataSourceDependantStep = Boolean(type && (type === RuleFormType.grafana || !!dataSourceName));
const showDataSourceDependantStep = Boolean(type && (isGrafanaManagedRuleByType(type) || !!dataSourceName));
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState));
@ -233,6 +235,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
);
const isPaused = existing && isGrafanaRulerRule(existing.rule) && isGrafanaRulerRulePaused(existing.rule);
if (!type) {
return null;
}
return (
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
@ -242,14 +247,14 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameInput />
<AlertRuleNameAndMetric />
{/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={!!existing} onDataChange={checkAlertCondition} />
{/* Step 3-4-5 */}
{showDataSourceDependantStep && (
<>
{/* Step 3 */}
{type === RuleFormType.grafana && (
{isGrafanaManagedRuleByType(type) && (
<GrafanaEvaluationBehavior
evaluateEvery={evaluateEvery}
setEvaluateEvery={setEvaluateEvery}
@ -266,7 +271,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
{/* Notifications step*/}
<NotificationsStep alertUid={uidFromParams} />
{/* Annotations only for cloud and Grafana */}
{type !== RuleFormType.cloudRecording && <AnnotationsStep />}
{!isRecordingRuleByType(type) && <AnnotationsStep />}
</>
)}
</Stack>
@ -285,7 +290,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
/>
) : null}
{showEditYaml ? (
type === RuleFormType.grafana ? (
isGrafanaManagedRuleByType(type) ? (
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
) : (
<RuleInspector onClose={() => setShowEditYaml(false)} />

View File

@ -21,8 +21,8 @@ import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } fr
import { isGrafanaRulerRule } from '../../../utils/rules';
import { FileExportPreview } from '../../export/FileExportPreview';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
import { allGrafanaExportProviders, ExportFormats } from '../../export/providers';
import { AlertRuleNameInput } from '../AlertRuleNameInput';
import { ExportFormats, allGrafanaExportProviders } from '../../export/providers';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
import { NotificationsStep } from '../NotificationsStep';
@ -88,7 +88,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameInput />
<AlertRuleNameAndMetric />
{/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} />
{/* Step 3-4-5 */}

View File

@ -1,7 +1,7 @@
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { mockRulerGrafanaRule } from '../../../mocks';
import { RuleFormValues } from '../../../types/rule-form';
import { mockRulerGrafanaRecordingRule, mockRulerGrafanaRule } from '../../../mocks';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { Annotation } from '../../../utils/constants';
import { getDefaultFormValues } from '../../../utils/rule-form';
@ -34,10 +34,19 @@ const rule3 = mockRulerGrafanaRule(
{ uid: 'uid-rule-3', title: 'Rule3', data: [] }
);
const rule4 = mockRulerGrafanaRecordingRule(
{
labels: { severity: 'notcritical4', region: 'region4' },
annotations: { [Annotation.summary]: 'This grafana rule4' },
},
{ uid: 'uid-rule-4', title: 'Rule4', data: [] }
);
// Prepare the form values for rule2 updated
const defaultValues = getDefaultFormValues();
const formValuesForRule2Updated: RuleFormValues = {
...defaultValues,
type: RuleFormType.grafana,
queries: [
{
refId: 'A',
@ -56,6 +65,26 @@ const formValuesForRule2Updated: RuleFormValues = {
labels: [{ key: 'newLabel', value: 'newLabel' }],
annotations: [{ key: 'summary', value: 'This grafana rule2 updated' }],
};
const formValuesForRecordingRule4Updated: RuleFormValues = {
...defaultValues,
type: RuleFormType.grafanaRecording,
queries: [
{
refId: 'A',
relativeTimeRange: { from: 900, to: 1000 },
datasourceUid: 'dsuid',
model: {
refId: 'A',
hide: true,
},
queryType: 'query',
},
],
condition: 'A',
name: 'Rule4 updated',
labels: [{ key: 'newLabel', value: 'newLabel' }],
annotations: [{ key: 'summary', value: 'This grafana rule4 updated' }],
};
const expectedModifiedRule2 = (uid: string) => ({
annotations: {
@ -90,24 +119,81 @@ const expectedModifiedRule2 = (uid: string) => ({
},
});
const expectedModifiedRule4 = (uid: string) => ({
annotations: {
summary: 'This grafana rule4 updated',
},
grafana_alert: {
condition: 'A',
data: [
{
datasourceUid: 'dsuid',
model: {
refId: 'A',
hide: true,
},
queryType: 'query',
refId: 'A',
relativeTimeRange: {
from: 900,
to: 1000,
},
},
],
is_paused: false,
notification_settings: undefined,
record: {
metric: 'Rule4 updated',
from: 'A',
},
title: 'Rule4 updated',
uid: uid,
},
labels: {
newLabel: 'newLabel',
},
});
describe('getPayloadFromDto', () => {
const groupDto: RulerRuleGroupDTO<RulerRuleDTO> = {
name: 'Test Group',
rules: [rule1, rule2, rule3],
rules: [rule1, rule2, rule3, rule4],
};
it('should return a ModifyExportPayload with the updated rule added to a group with this rule belongs, in the same position', () => {
const result = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto);
expect(result).toEqual({
// for alerting rule
const resultForAlerting = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto);
expect(resultForAlerting).toEqual({
name: 'Test Group',
rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3],
rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3, rule4],
});
// for recording rule
const resultForRecording = getPayloadToExport(
'uid-rule-4',
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
groupDto
);
expect(resultForRecording).toEqual({
name: 'Test Group',
rules: [rule1, rule2, rule3, expectedModifiedRule4('uid-rule-4')],
});
});
it('should return a ModifyExportPayload with the updated rule added to a non empty rule where this rule does not belong, in the last position', () => {
// for alerting rule
const result = getPayloadToExport('uid-rule-5', formValuesForRule2Updated, groupDto);
expect(result).toEqual({
name: 'Test Group',
rules: [rule1, rule2, rule3, expectedModifiedRule2('uid-rule-5')],
rules: [rule1, rule2, rule3, rule4, expectedModifiedRule2('uid-rule-5')],
});
// for recording rule
const resultForRecording = getPayloadToExport(
'uid-rule-5',
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
groupDto
);
expect(resultForRecording).toEqual({
name: 'Test Group',
rules: [rule1, rule2, rule3, rule4, expectedModifiedRule4('uid-rule-5')],
});
});

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@ -18,6 +18,13 @@ import { fetchAllPromBuildInfoAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form';
import {
isCloudAlertingRuleByType,
isCloudRecordingRuleByType,
isDataSourceManagedRuleByType,
isGrafanaAlertingRuleByType,
isGrafanaManagedRuleByType,
} from '../../../utils/rules';
import { ExpressionEditor } from '../ExpressionEditor';
import { ExpressionsEditor } from '../ExpressionsEditor';
import { NeedHelpInfo } from '../NeedHelpInfo';
@ -70,9 +77,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana;
const isRecordingRuleType = type === RuleFormType.cloudRecording;
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type);
const isRecordingRuleType = isCloudRecordingRuleByType(type);
const isCloudAlertRuleType = isCloudAlertingRuleByType(type);
const dispatchReduxAction = useDispatch();
useEffect(() => {
@ -114,9 +121,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const emptyQueries = queries.length === 0;
// apply some validations and asserts to the results of the evaluation when creating or editing
// Grafana-managed alert rules
// Grafana-managed alert rules and Grafa-managed recording rules
useEffect(() => {
if (!isGrafanaManagedType) {
if (type && !isGrafanaManagedRuleByType(type)) {
return;
}
@ -133,7 +140,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const error = errorFromPreviewData(previewData) ?? errorFromCurrentCondition(previewData);
onDataChange(error?.message || '');
}, [queryPreviewData, getValues, onDataChange, isGrafanaManagedType]);
}, [queryPreviewData, getValues, onDataChange, type]);
const handleSetCondition = useCallback(
(refId: string | null) => {
@ -361,7 +368,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
]);
const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana];
if (!type) {
return null;
}
return (
<RuleEditorSection
stepNo={2}
@ -381,7 +390,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
}
>
{/* This is the cloud data source selector */}
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && (
{isDataSourceManagedRuleByType(type) && (
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} />
)}
@ -429,8 +438,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
</Stack>
)}
{/* This is the editor for Grafana managed rules */}
{isGrafanaManagedType && (
{/* This is the editor for Grafana managed rules and Grafana managed recording rules */}
{isGrafanaManagedRuleByType(type) && (
<Stack direction="column">
{/* Data Queries */}
<QueryEditor
@ -457,12 +466,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
Add query
</Button>
</Tooltip>
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries}
onClickSwitch={onClickSwitch}
/>
{/* We only show Switch for Grafana managed alerts */}
{isGrafanaAlertingType && (
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries}
onClickSwitch={onClickSwitch}
/>
)}
{/* Expression Queries */}
<Stack direction="column" gap={0}>
<Text element="h5">Expressions</Text>

View File

@ -15,6 +15,13 @@ export const DESCRIPTIONS: Record<RuleFormType, FormDescriptions> = {
'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.',
helpLink: '',
},
[RuleFormType.grafanaRecording]: {
sectionTitle: 'Define recording rule',
helpLabel: 'Define your recording rule',
helpContent:
'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.',
helpLink: '',
},
[RuleFormType.grafana]: {
sectionTitle: 'Define query and alert condition',
helpLabel: 'Define query and alert condition',

View File

@ -327,6 +327,10 @@ export function translateRouteParamToRuleType(param = ''): RuleFormType {
return RuleFormType.cloudRecording;
}
if (param === 'grafana-recording') {
return RuleFormType.grafanaRecording;
}
return RuleFormType.grafana;
}

View File

@ -20,6 +20,7 @@ import {
getRulePluginOrigin,
isAlertingRule,
isFederatedRuleGroup,
isGrafanaRecordingRule,
isGrafanaRulerRule,
isGrafanaRulerRulePaused,
isRecordingRule,
@ -192,6 +193,13 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
),
});
}
if (isGrafanaRecordingRule(rule.rulerRule)) {
const metric = rule.rulerRule?.grafana_alert.record?.metric ?? '';
metadata.push({
label: 'Metric name',
value: <Text color="primary">{metric}</Text>,
});
}
if (interval) {
metadata.push({

View File

@ -3,7 +3,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Text, Stack, useStyles2, ClipboardButton, TextLink } from '@grafana/ui';
import { ClipboardButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting';
import { Annotations } from 'app/types/unified-alerting-dto';
@ -98,18 +98,21 @@ const Details = ({ rule }: DetailsProps) => {
</MetaText>
{/* nodata and execution error state mapping */}
{isGrafanaRulerRule(rule.rulerRule) && (
<>
<MetaText direction="column">
Alert state if no data or all values are null
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
</MetaText>
<MetaText direction="column">
Alert state if execution error or timeout
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
</MetaText>
</>
)}
{isGrafanaRulerRule(rule.rulerRule) &&
// grafana recording rules don't have these fields
rule.rulerRule.grafana_alert.no_data_state &&
rule.rulerRule.grafana_alert.exec_err_state && (
<>
<MetaText direction="column">
Alert state if no data or all values are null
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
</MetaText>
<MetaText direction="column">
Alert state if execution error or timeout
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
</MetaText>
</>
)}
</div>
{/* annotations go here */}

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { LinkButton, LoadingPlaceholder, Pagination, Spinner, useStyles2, Text } from '@grafana/ui';
import { LinkButton, LoadingPlaceholder, Pagination, Spinner, Text, useStyles2 } from '@grafana/ui';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
@ -136,6 +136,7 @@ export function CreateRecordingRuleButton() {
href={urlUtil.renderUrl(`alerting/new/recording`, {
returnTo: location.pathname + location.search,
})}
tooltip="Create new Data source-managed recording rule"
icon="plus"
variant="secondary"
>

View File

@ -4,24 +4,24 @@ import { useMemo } from 'react';
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, Field, Input, Label, LinkButton, Modal, useStyles2, Stack, Alert } from '@grafana/ui';
import { Alert, Badge, Button, Field, Input, Label, LinkButton, Modal, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import {
useUpdateRuleGroupConfiguration,
useRenameRuleGroup,
useMoveRuleGroup,
useRenameRuleGroup,
useUpdateRuleGroupConfiguration,
} from '../../hooks/ruleGroup/useUpdateRuleGroup';
import { anyOfRequestState } from '../../hooks/useAsync';
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
import { stringifyErrorLike } from '../../utils/misc';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules';
import { AlertInfo, getAlertInfo, isGrafanaOrDataSourceRecordingRule } from '../../utils/rules';
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
@ -75,7 +75,8 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou
}))
.sort(
(alert1, alert2) =>
safeParsePrometheusDuration(alert1.data.forDuration) - safeParsePrometheusDuration(alert2.data.forDuration)
safeParsePrometheusDuration(alert1.data.forDuration ?? '') -
safeParsePrometheusDuration(alert2.data.forDuration ?? '')
);
const columns: AlertsWithForTableColumnProps[] = useMemo(() => {
@ -154,9 +155,11 @@ export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterO
} else {
const rulePendingPeriods = rules.map((rule) => {
const { forDuration } = getAlertInfo(rule, evaluateEvery);
return safeParsePrometheusDuration(forDuration);
return forDuration ? safeParsePrometheusDuration(forDuration) : null;
});
const largestPendingPeriod = Math.min(...rulePendingPeriods);
const largestPendingPeriod = Math.min(
...rulePendingPeriods.filter((period): period is number => period !== null)
);
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(largestPendingPeriod)}".`;
}
} catch (error) {
@ -262,7 +265,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
};
const rulesWithoutRecordingRules = compact(
group.rules.map((r) => r.rulerRule).filter((rule) => !isRecordingRulerRule(rule))
group.rules.map((r) => r.rulerRule).filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule))
);
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
const modalTitle =

View File

@ -2,11 +2,14 @@ import { css } from '@emotion/css';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, LoadingPlaceholder, Pagination, Spinner, useStyles2, Text } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Button, LinkButton, LoadingPlaceholder, Pagination, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans, t } from 'app/core/internationalization';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { LogMessages, logInfo } from '../../Analytics';
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces';
import { usePagination } from '../../hooks/usePagination';
@ -14,6 +17,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
import { getPaginationStyles } from '../../styles/pagination';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { createUrl } from '../../utils/url';
import { GrafanaRulesExporter } from '../export/GrafanaRulesExporter';
import { RulesGroup } from './RulesGroup';
@ -53,26 +57,47 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => {
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const hasGrafanaAlerts = namespaces.length > 0;
const grafanaRecordingRulesEnabled = config.featureToggles.grafanaManagedRecordingRules;
return (
<section className={styles.wrapper}>
<div className={styles.sectionHeader}>
<div className={styles.headerRow}>
<Text element="h2" variant="h5">
Grafana
<Trans i18nKey="alerting.grafana-rules.title">Grafana</Trans>
</Text>
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />}
{hasGrafanaAlerts && canExportRules && (
<Button
aria-label="export all grafana rules"
data-testid="export-all-grafana-rules"
icon="download-alt"
tooltip="Export all Grafana-managed rules"
onClick={toggleShowExportDrawer}
variant="secondary"
>
Export rules
</Button>
{loading ? (
<LoadingPlaceholder className={styles.loader} text={t('alerting.grafana-rules.loading', 'Loading...')} />
) : (
<div />
)}
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{hasGrafanaAlerts && canExportRules && (
<Button
aria-label="export all grafana rules"
data-testid="export-all-grafana-rules"
icon="download-alt"
tooltip="Export all Grafana-managed rules"
onClick={toggleShowExportDrawer}
variant="secondary"
>
<Trans i18nKey="alerting.grafana-rules.export-rules">Export rules</Trans>
</Button>
)}
{grafanaRecordingRulesEnabled && (
<LinkButton
href={createUrl('/alerting/new/grafana-recording', {
returnTo: '/alerting/list' + location.search,
})}
icon="plus"
variant="secondary"
tooltip="Create new Grafana-managed recording rule"
onClick={() => logInfo(LogMessages.grafanaRecording)}
>
<Trans i18nKey="alerting.grafana-rules.new-recording-rule">New recording rule</Trans>
</LinkButton>
)}
</Stack>
</div>
</div>

View File

@ -3,7 +3,7 @@ import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, useStyles2, Stack } from '@grafana/ui';
import { LinkButton, Stack, useStyles2 } from '@grafana/ui';
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
@ -18,7 +18,7 @@ import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules';
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
import { RedirectToCloneRule } from './CloneRule';
@ -138,7 +138,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
}}
/>
{deleteModal}
{isGrafanaRulerRule(rule.rulerRule) && showSilenceDrawer && (
{isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && (
<AlertmanagerProvider accessType="instance">
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
</AlertmanagerProvider>

View File

@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, dateTime, dateTimeFormat } from '@grafana/data';
import { useStyles2, Tooltip } from '@grafana/ui';
import { Tooltip, useStyles2 } from '@grafana/ui';
import { Time } from 'app/features/explore/Time';
import { CombinedRule } from 'app/types/unified-alerting';
import { useCleanAnnotations } from '../../utils/annotations';
import { isRecordingRulerRule } from '../../utils/rules';
import { isGrafanaRecordingRule, isRecordingRulerRule } from '../../utils/rules';
import { isNullDate } from '../../utils/time';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
@ -68,6 +68,7 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
let every = rule.group.interval;
let lastEvaluation = rule.promRule?.lastEvaluation;
let lastEvaluationDuration = rule.promRule?.evaluationTime;
const metric = isGrafanaRecordingRule(rule.rulerRule) ? rule.rulerRule?.grafana_alert.record?.metric : undefined;
// recording rules don't have a for duration
if (!isRecordingRulerRule(rule.rulerRule)) {
@ -76,6 +77,11 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
return (
<>
{metric && (
<DetailsField label="Metric" horizontal={true}>
{metric}
</DetailsField>
)}
{every && (
<DetailsField label="Evaluate" horizontal={true}>
Every {every}

View File

@ -10,7 +10,7 @@ import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { Annotation } from '../../utils/constants';
import { isCloudRulesSource } from '../../utils/datasource';
import { createExploreLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { isFederatedRuleGroup, isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
interface Props {
rule: CombinedRule;
@ -103,7 +103,7 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
}
}
if (isGrafanaRulerRule(rule.rulerRule)) {
if (isGrafanaAlertingRule(rule.rulerRule)) {
buttons.push(
<Fragment key="history">
<Button

View File

@ -2,11 +2,13 @@ import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
import { Spinner, useStyles2, Stack } from '@grafana/ui';
import { Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { CombinedRule } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { isAlertingRule, isRecordingRule, getFirstActiveAt } from '../../utils/rules';
import { getFirstActiveAt, isAlertingRule, isGrafanaRecordingRule, isRecordingRule } from '../../utils/rules';
import { StateTag } from '../StateTag';
import { AlertStateTag } from './AlertStateTag';
@ -19,9 +21,22 @@ interface Props {
export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) => {
const style = useStyles2(getStyle);
const { promRule } = rule;
const { promRule, rulerRule } = rule;
// return how long the rule has been in its firing state, if any
const RecordingRuleState = () => {
if (isPaused && isGrafanaRecordingRule(rulerRule)) {
return (
<Tooltip content={'Recording rule evaluation is currently paused'} placement="top">
<StateTag state="warning">
<Icon name="pause" size="xs" />
<Trans i18nKey="alerting.rule-state.paused">Paused</Trans>
</StateTag>
</Tooltip>
);
} else {
return <Trans i18nKey="alerting.rule-state.recording-rule">Recording rule</Trans>;
}
};
const forTime = useMemo(() => {
if (
promRule &&
@ -55,14 +70,14 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
return (
<Stack gap={1}>
<Spinner />
Deleting
<Trans i18nKey="alerting.rule-state.deleting">Deleting</Trans>
</Stack>
);
} else if (isCreating) {
return (
<Stack gap={1}>
<Spinner />
Creating
<Trans i18nKey="alerting.rule-state.creating">Creating</Trans>
</Stack>
);
} else if (promRule && isAlertingRule(promRule)) {
@ -73,7 +88,7 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
</Stack>
);
} else if (promRule && isRecordingRule(promRule)) {
return <>Recording rule</>;
return <RecordingRuleState />;
}
return <>n/a</>;
};

View File

@ -11,7 +11,7 @@ import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
import { useIsRuleEditable } from './useIsRuleEditable';
@ -284,6 +284,7 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
function useCanSilence(rule: CombinedRule): [boolean, boolean] {
const rulesSource = rule.namespace.rulesSource;
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
const isGrafanaRecording = isGrafanaRecordingRule(rule.rulerRule);
const { currentData: amConfigStatus, isLoading } =
alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, {
@ -293,9 +294,9 @@ function useCanSilence(rule: CombinedRule): [boolean, boolean] {
const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID);
// we don't support silencing when the rule is not a Grafana managed rule
// we don't support silencing when the rule is not a Grafana managed alerting rule
// we simply don't know what Alertmanager the ruler is sending alerts to
if (!isGrafanaManagedRule || isLoading || folderIsLoading || !folder) {
if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) {
return [false, false];
}

View File

@ -142,6 +142,42 @@ export const mockRulerGrafanaRule = (
...partial,
};
};
export const mockRulerGrafanaRecordingRule = (
partial: Partial<RulerGrafanaRuleDTO> = {},
partialDef: Partial<GrafanaRuleDefinition> = {}
): RulerGrafanaRuleDTO => {
return {
grafana_alert: {
uid: '123',
title: 'myalert',
namespace_uid: '123',
rule_group: 'my-group',
condition: 'A',
record: {
metric: 'myalert',
from: 'A',
},
data: [
{
datasourceUid: '123',
refId: 'A',
queryType: 'huh',
model: {
refId: '',
},
},
],
...partialDef,
},
annotations: {
message: 'alert with severity "{{.warning}}}"',
},
labels: {
severity: 'warning',
},
...partial,
};
};
export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {}): RulerAlertingRuleDTO => ({
alert: 'alert1',

View File

@ -52,7 +52,7 @@ import { discoverFeatures } from '../api/buildInfo';
import { fetchNotifiers } from '../api/grafana';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import {
GRAFANA_RULES_SOURCE_NAME,
@ -64,7 +64,13 @@ import { makeAMLink } from '../utils/misc';
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux';
import * as ruleId from '../utils/rule-id';
import { getRulerClient } from '../utils/rulerClient';
import { getAlertInfo, isGrafanaRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
import {
getAlertInfo,
isDataSourceManagedRuleByType,
isGrafanaManagedRuleByType,
isGrafanaRulerRule,
isRulerNotSupportedResponse,
} from '../utils/rules';
import { safeParsePrometheusDuration } from '../utils/time';
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
@ -357,13 +363,16 @@ export const saveRuleFormAction = createAsyncThunk(
withSerializedError(
(async () => {
const { type } = values;
if (!type) {
return;
}
// TODO getRulerConfig should be smart enough to provide proper rulerClient implementation
// For the dataSourceName specified
// in case of system (cortex/loki)
let identifier: RuleIdentifier;
if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) {
if (isDataSourceManagedRuleByType(type)) {
if (!values.dataSourceName) {
throw new Error('The Data source has not been defined.');
}
@ -372,8 +381,8 @@ export const saveRuleFormAction = createAsyncThunk(
const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
// in case of grafana managed
} else if (type === RuleFormType.grafana) {
// in case of grafana managed rules or grafana-managed recording rules
} else if (isGrafanaManagedRuleByType(type)) {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
@ -656,10 +665,10 @@ export const testReceiversAction = createAsyncThunk(
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
return rules.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration);
const forNumber = safeParsePrometheusDuration(forDuration);
const forNumber = forDuration ? safeParsePrometheusDuration(forDuration) : null;
const everyNumber = safeParsePrometheusDuration(everyDuration);
return forNumber !== 0 && forNumber < everyNumber;
return forNumber ? forNumber !== 0 && forNumber < everyNumber : false;
});
};

View File

@ -3,7 +3,8 @@ import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alertin
import { Folder } from '../components/rule-editor/RuleFolderPicker';
export enum RuleFormType {
grafana = 'grafana',
grafana = 'grafana-alerting',
grafanaRecording = 'grafana-recording',
cloudAlerting = 'cloud-alerting',
cloudRecording = 'cloud-recording',
}
@ -45,6 +46,7 @@ export interface RuleFormValues {
isPaused?: boolean;
manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule
contactPoints?: AlertManagerManualRouting;
metric?: string;
// cortex / loki rules
namespace: string;

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values 1`] = `
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana alerting rule 1`] = `
{
"annotations": {
"description": "",
@ -23,6 +23,29 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
}
`;
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana recording rule 1`] = `
{
"annotations": {
"description": "",
"runbook_url": "",
"summary": "",
},
"grafana_alert": {
"condition": "A",
"data": [],
"is_paused": false,
"record": {
"from": "A",
"metric": "",
},
"title": "",
},
"labels": {
"": "",
},
}
`;
exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = `
{
"annotations": {

View File

@ -20,7 +20,7 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null
if (isGrafanaRulerRule(rulerRule)) {
const query = rulerRule.grafana_alert.data;
return widenRelativeTimeRanges(query, rulerRule.for, combinedRule.group.interval);
return widenRelativeTimeRanges(query, rulerRule.for ?? '', combinedRule.group.interval);
}
if (isCloudRulesSource(rulesSource)) {

View File

@ -17,10 +17,21 @@ import {
} from './rule-form';
describe('formValuesToRulerGrafanaRuleDTO', () => {
it('should correctly convert rule form values', () => {
it('should correctly convert rule form values for grafana alerting rule', () => {
const formValues: RuleFormValues = {
...getDefaultFormValues(),
condition: 'A',
type: RuleFormType.grafana,
};
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
});
it('should correctly convert rule form values for grafana recording rule', () => {
const formValues: RuleFormValues = {
...getDefaultFormValues(),
condition: 'A',
type: RuleFormType.grafanaRecording,
};
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
@ -31,6 +42,7 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
const values: RuleFormValues = {
...defaultValues,
type: RuleFormType.grafana,
queries: [
{
refId: 'A',

View File

@ -47,7 +47,13 @@ import { getRulesAccess } from './access-control';
import { Annotation, defaultAnnotations } from './constants';
import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import {
isAlertingRulerRule,
isGrafanaAlertingRuleByType,
isGrafanaRecordingRule,
isGrafanaRulerRule,
isRecordingRulerRule,
} from './rules';
import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time';
export type PromOrLokiQuery = PromQuery | LokiQuery;
@ -194,28 +200,61 @@ export function getNotificationSettingsForDTO(
}
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused, contactPoints, manualRouting } =
values;
const {
name,
condition,
noDataState,
execErrState,
evaluateFor,
queries,
isPaused,
contactPoints,
manualRouting,
type,
metric,
} = values;
if (condition) {
const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO(
manualRouting,
contactPoints
);
if (isGrafanaAlertingRuleByType(type)) {
return {
grafana_alert: {
title: name,
condition,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
return {
grafana_alert: {
title: name,
condition,
no_data_state: noDataState,
exec_err_state: execErrState,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
notification_settings: notificationSettings,
},
for: evaluateFor,
annotations: arrayToRecord(values.annotations || []),
labels: arrayToRecord(values.labels || []),
};
// Alerting rule specific
no_data_state: noDataState,
exec_err_state: execErrState,
notification_settings: notificationSettings,
},
annotations: arrayToRecord(values.annotations || []),
labels: arrayToRecord(values.labels || []),
// Alerting rule specific
for: evaluateFor,
};
} else {
return {
grafana_alert: {
title: name,
condition,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
// Recording rule specific
record: {
metric: metric ?? name,
from: condition,
},
},
annotations: arrayToRecord(values.annotations || []),
labels: arrayToRecord(values.labels || []),
};
}
}
throw new Error('Cannot create rule without specifying alert condition');
}
@ -251,34 +290,56 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
const defaultFormValues = getDefaultFormValues();
if (isGrafanaRulesSource(ruleSourceName)) {
if (isGrafanaRulerRule(rule)) {
// GRAFANA-MANAGED RULES
if (isGrafanaRecordingRule(rule)) {
// grafana recording rule
const ga = rule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
return {
...defaultFormValues,
name: ga.title,
type: RuleFormType.grafana,
type: RuleFormType.grafanaRecording,
group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
evaluateFor: rule.for || '0',
noDataState: ga.no_data_state,
execErrState: ga.exec_err_state,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
contactPoints: routingSettings,
manualRouting: Boolean(routingSettings),
metric: ga.record?.metric,
};
} else if (isGrafanaRulerRule(rule)) {
// grafana alerting rule
const ga = rule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) {
return {
...defaultFormValues,
name: ga.title,
type: RuleFormType.grafana,
group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
evaluateFor: rule.for || '0',
noDataState: ga.no_data_state,
execErrState: ga.exec_err_state,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
contactPoints: routingSettings,
manualRouting: Boolean(routingSettings),
};
} else {
throw new Error('Unexpected type of rule for grafana rules source');
}
} else {
throw new Error('Unexpected type of rule for grafana rules source');
}
} else {
// DATASOURCE-MANAGED RULES
if (isAlertingRulerRule(rule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';

View File

@ -10,19 +10,18 @@ import {
CombinedRuleGroup,
CombinedRuleWithLocation,
GrafanaRuleIdentifier,
PrometheusRuleIdentifier,
PromRuleWithLocation,
PrometheusRuleIdentifier,
RecordingRule,
Rule,
RuleIdentifier,
RuleGroupIdentifier,
RuleIdentifier,
RuleNamespace,
RuleWithLocation,
} from 'app/types/unified-alerting';
import {
GrafanaAlertState,
GrafanaAlertStateWithReason,
mapStateWithReasonToBaseState,
PostableRuleDTO,
PromAlertingRuleState,
PromRuleType,
@ -31,11 +30,13 @@ import {
RulerGrafanaRuleDTO,
RulerRecordingRuleDTO,
RulerRuleDTO,
mapStateWithReasonToBaseState,
} from 'app/types/unified-alerting-dto';
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { State } from '../components/StateTag';
import { RuleHealth } from '../search/rulesSearchParser';
import { RuleFormType } from '../types/rule-form';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName, isGrafanaRulesSource } from './datasource';
@ -59,10 +60,29 @@ export function isRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordin
return typeof rule === 'object' && 'record' in rule;
}
export function isGrafanaOrDataSourceRecordingRule(rule?: RulerRuleDTO) {
return (
(typeof rule === 'object' && isRecordingRulerRule(rule)) ||
(isGrafanaRulerRule(rule) && 'record' in rule.grafana_alert)
);
}
export function isGrafanaRecordingRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
return typeof rule === 'object' && isGrafanaOrDataSourceRecordingRule(rule) && isGrafanaRulerRule(rule);
}
export function isGrafanaAlertingRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
return typeof rule === 'object' && isGrafanaRulerRule(rule) && !isGrafanaOrDataSourceRecordingRule(rule);
}
export function isGrafanaRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerGrafanaRuleDTO {
return typeof rule === 'object' && 'grafana_alert' in rule;
}
export function isGrafanaRecordingRulerRule(rule?: RulerRuleDTO) {
return typeof rule === 'object' && 'grafana_alert' in rule && 'record' in rule.grafana_alert;
}
export function isCloudRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerCloudRuleDTO {
return typeof rule === 'object' && !isGrafanaRulerRule(rule);
}
@ -254,8 +274,8 @@ export function getRuleName(rule: RulerRuleDTO) {
export interface AlertInfo {
alertName: string;
forDuration: string;
evaluationsToFire: number;
forDuration?: string;
evaluationsToFire: number | null;
}
export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => {
@ -268,7 +288,7 @@ export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): Al
return {
alertName: alert.grafana_alert.title,
forDuration: alert.for,
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation),
evaluationsToFire: alert.for ? getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation) : null,
};
}
if (isAlertingRulerRule(alert)) {
@ -329,3 +349,31 @@ export function getRuleGroupLocationFromRuleWithLocation(rule: RuleWithLocation)
groupName,
};
}
export function isGrafanaAlertingRuleByType(type?: RuleFormType) {
return type === RuleFormType.grafana;
}
export function isGrafanaRecordingRuleByType(type: RuleFormType) {
return type === RuleFormType.grafanaRecording;
}
export function isCloudAlertingRuleByType(type?: RuleFormType) {
return type === RuleFormType.cloudAlerting;
}
export function isCloudRecordingRuleByType(type?: RuleFormType) {
return type === RuleFormType.cloudRecording;
}
export function isGrafanaManagedRuleByType(type: RuleFormType) {
return isGrafanaAlertingRuleByType(type) || isGrafanaRecordingRuleByType(type);
}
export function isRecordingRuleByType(type: RuleFormType) {
return isGrafanaRecordingRuleByType(type) || isCloudRecordingRuleByType(type);
}
export function isDataSourceManagedRuleByType(type: RuleFormType) {
return isCloudAlertingRuleByType(type) || isCloudRecordingRuleByType(type);
}

View File

@ -108,6 +108,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "C",
},
],
"type": "grafana",
"type": "grafana-alerting",
}
`;

View File

@ -222,11 +222,15 @@ export interface PostableGrafanaRuleDefinition {
uid?: string;
title: string;
condition: string;
no_data_state: GrafanaAlertStateDecision;
exec_err_state: GrafanaAlertStateDecision;
no_data_state?: GrafanaAlertStateDecision;
exec_err_state?: GrafanaAlertStateDecision;
data: AlertQuery[];
is_paused?: boolean;
notification_settings?: GrafanaNotificationSettings;
record?: {
metric: string;
from: string;
};
}
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
id?: string;
@ -238,7 +242,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
export interface RulerGrafanaRuleDTO<T = GrafanaRuleDefinition> {
grafana_alert: T;
for: string;
for?: string;
annotations: Annotations;
labels: Labels;
}

View File

@ -112,6 +112,12 @@
"used-by_one": "Used by {{ count }} notification policy",
"used-by_other": "Used by {{ count }} notification policies"
},
"grafana-rules": {
"export-rules": "Export rules",
"loading": "Loading...",
"new-recording-rule": "New recording rule",
"title": "Grafana"
},
"policies": {
"metadata": {
"timingOptions": {
@ -144,6 +150,12 @@
"success": "Successfully updated rule group"
}
},
"rule-state": {
"creating": "Creating",
"deleting": "Deleting",
"paused": "Paused",
"recording-rule": "Recording rule"
},
"rules": {
"delete-rule": {
"success": "Rule successfully deleted"

View File

@ -112,6 +112,12 @@
"used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy",
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş"
},
"grafana-rules": {
"export-rules": "Ēχpőřŧ řūľęş",
"loading": "Ŀőäđįʼnģ...",
"new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę",
"title": "Ğřäƒäʼnä"
},
"policies": {
"metadata": {
"timingOptions": {
@ -144,6 +150,12 @@
"success": "Ŝūččęşşƒūľľy ūpđäŧęđ řūľę ģřőūp"
}
},
"rule-state": {
"creating": "Cřęäŧįʼnģ",
"deleting": "Đęľęŧįʼnģ",
"paused": "Päūşęđ",
"recording-rule": "Ŗęčőřđįʼnģ řūľę"
},
"rules": {
"delete-rule": {
"success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ"