From 8423d06988077c92239e7852c75fcb99eb9f59a5 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:52:22 +0200 Subject: [PATCH] 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 * update snapshot --------- Co-authored-by: Tom Ratcliffe --- .betterer.results | 9 +- .../features/alerting/unified/Analytics.ts | 1 + .../features/alerting/unified/RuleEditor.tsx | 9 +- .../PanelAlertTabContent.test.tsx.snap | 2 +- .../rule-editor/AlertRuleNameInput.tsx | 72 +++++++---- .../rule-editor/GrafanaEvaluationBehavior.tsx | 81 ++++++------ .../rule-editor/NotificationsStep.tsx | 6 +- .../components/rule-editor/PreviewRule.tsx | 5 +- .../alert-rule-form/AlertRuleForm.tsx | 21 ++-- .../alert-rule-form/ModifyExportRuleForm.tsx | 6 +- .../getPayloadToExport.test.ts | 100 +++++++++++++-- .../QueryAndExpressionsStep.tsx | 46 ++++--- .../descriptions.tsx | 7 ++ .../unified/components/rule-editor/util.ts | 4 + .../components/rule-viewer/RuleViewer.tsx | 8 ++ .../components/rule-viewer/tabs/Details.tsx | 29 +++-- .../unified/components/rules/CloudRules.tsx | 3 +- .../components/rules/EditRuleGroupModal.tsx | 21 ++-- .../unified/components/rules/GrafanaRules.tsx | 53 +++++--- .../components/rules/RuleActionsButtons.tsx | 6 +- .../unified/components/rules/RuleDetails.tsx | 10 +- .../components/rules/RuleDetailsButtons.tsx | 4 +- .../unified/components/rules/RuleState.tsx | 29 +++-- .../alerting/unified/hooks/useAbilities.ts | 7 +- public/app/features/alerting/unified/mocks.ts | 36 ++++++ .../alerting/unified/state/actions.ts | 23 ++-- .../alerting/unified/types/rule-form.ts | 4 +- .../__snapshots__/rule-form.test.ts.snap | 25 +++- .../features/alerting/unified/utils/query.ts | 2 +- .../alerting/unified/utils/rule-form.test.ts | 14 ++- .../alerting/unified/utils/rule-form.ts | 117 +++++++++++++----- .../features/alerting/unified/utils/rules.ts | 60 ++++++++- .../PanelDataAlertingTab.test.tsx.snap | 2 +- public/app/types/unified-alerting-dto.ts | 10 +- public/locales/en-US/grafana.json | 12 ++ public/locales/pseudo-LOCALE/grafana.json | 12 ++ 36 files changed, 647 insertions(+), 209 deletions(-) diff --git a/.betterer.results b/.betterer.results index d7fb11a15f9..07d8e8267e8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2280,9 +2280,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/alerting/unified/components/rules/GrafanaRules.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], "public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index a58c0d2b503..f96e20cd918 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -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', }; diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 5e58a5dd206..4dfa212e4e3 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -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 = { icon: 'bell', @@ -25,8 +28,8 @@ const defaultPageNav: Partial = { }; // 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' }; diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index eecab06dfdb..bb0d4a46ec3 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -112,6 +112,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button "refId": "C", }, ], - "type": "grafana", + "type": "grafana-alerting", } `; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx index 983d6c781e2..746783adebd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx @@ -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(); 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 ( { } > - - - + + + + + {isGrafanaRecordingRule && ( + + + + )} + ); }; diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index b98c50e6c40..35ad15987c1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -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(); 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} /> - + {/* Show the pending period input only for Grafana alerting rules */} + {isGrafanaAlertingRule && } {existing && ( @@ -327,44 +332,48 @@ export function GrafanaEvaluationBehavior({ )} - setShowErrorHandling(!collapsed)} - text="Configure no data and error handling" - /> - {showErrorHandling && ( + {isGrafanaAlertingRule && ( <> - - - ( - onChange(value?.value)} + setShowErrorHandling(!collapsed)} + text="Configure no data and error handling" + /> + {showErrorHandling && ( + <> + + + ( + onChange(value?.value)} + /> + )} + name="noDataState" /> - )} - name="noDataState" - /> - - - ( - onChange(value?.value)} + + + ( + onChange(value?.value)} + /> + )} + name="execErrState" /> - )} - name="execErrState" - /> - + + + )} )} diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index 03cfdf33161..3a780463010 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -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 ( {type === RuleFormType.cloudRecording ? ( diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx index 170b849e030..d5d1de46056 100644 --- a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx @@ -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; } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index c587a9d87b2..8c80bdaecd7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -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 ( @@ -242,14 +247,14 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {/* Step 1 */} - + {/* Step 2 */} {/* Step 3-4-5 */} {showDataSourceDependantStep && ( <> {/* Step 3 */} - {type === RuleFormType.grafana && ( + {isGrafanaManagedRuleByType(type) && ( { {/* Notifications step*/} {/* Annotations only for cloud and Grafana */} - {type !== RuleFormType.cloudRecording && } + {!isRecordingRuleByType(type) && } )} @@ -285,7 +290,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { /> ) : null} {showEditYaml ? ( - type === RuleFormType.grafana ? ( + isGrafanaManagedRuleByType(type) ? ( setShowEditYaml(false)} /> ) : ( setShowEditYaml(false)} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index 3efe5311328..3304bd0e9d8 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -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 {/* Step 1 */} - + {/* Step 2 */} {/* Step 3-4-5 */} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts index cedaff19f6d..402de30ce0d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts @@ -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 = { 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')], }); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index a5c9ebac579..ed2f94a7542 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -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 ( {/* This is the cloud data source selector */} - {(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && ( + {isDataSourceManagedRuleByType(type) && ( )} @@ -429,8 +438,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P )} - {/* This is the editor for Grafana managed rules */} - {isGrafanaManagedType && ( + {/* This is the editor for Grafana managed rules and Grafana managed recording rules */} + {isGrafanaManagedRuleByType(type) && ( {/* Data Queries */} - + {/* We only show Switch for Grafana managed alerts */} + {isGrafanaAlertingType && ( + + )} {/* Expression Queries */} Expressions diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx index fced3fa06a5..861b4fd9f96 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/descriptions.tsx @@ -15,6 +15,13 @@ export const DESCRIPTIONS: Record = { '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', diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index 258ffc74964..7c09766a2db 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -327,6 +327,10 @@ export function translateRouteParamToRuleType(param = ''): RuleFormType { return RuleFormType.cloudRecording; } + if (param === 'grafana-recording') { + return RuleFormType.grafanaRecording; + } + return RuleFormType.grafana; } diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 13922c65f58..a7d8db1a21b 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -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: {metric}, + }); + } if (interval) { metadata.push({ diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 6999e5a18ea..5c723326abb 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -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) => { {/* nodata and execution error state mapping */} - {isGrafanaRulerRule(rule.rulerRule) && ( - <> - - Alert state if no data or all values are null - {rule.rulerRule.grafana_alert.no_data_state} - - - Alert state if execution error or timeout - {rule.rulerRule.grafana_alert.exec_err_state} - - - )} + {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 && ( + <> + + Alert state if no data or all values are null + {rule.rulerRule.grafana_alert.no_data_state} + + + Alert state if execution error or timeout + {rule.rulerRule.grafana_alert.exec_err_state} + + + )} {/* annotations go here */} diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 54cb3493cce..e7bf42ea817 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -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" > diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 93df670343b..d119d68b47a 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -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 = diff --git a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx index 3cf53dc6e6a..22468e81c0d 100644 --- a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx +++ b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx @@ -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 (
- Grafana + Grafana - {loading ? :
} - {hasGrafanaAlerts && canExportRules && ( - + {loading ? ( + + ) : ( +
)} + + {hasGrafanaAlerts && canExportRules && ( + + )} + {grafanaRecordingRulesEnabled && ( + logInfo(LogMessages.grafanaRecording)} + > + New recording rule + + )} +
diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 2d7b45ba56b..33b1dd051a5 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -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 && ( setShowSilenceDrawer(false)} /> diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index db62f8e624f..16086353b99 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -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 && ( + + {metric} + + )} {every && ( Every {every} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx index 558ae99315c..079a26dcf48 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx @@ -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(