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"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
], ],
"public/app/features/alerting/unified/components/rules/GrafanaRules.tsx:5381": [ "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 />", "0"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
], ],
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [ "public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [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": [ "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 />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [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"]
], ],
"public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [ "public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [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', cancelSavingAlertRule: 'user canceled alert rule creation',
successSavingAlertRule: 'alert rule saved successfully', successSavingAlertRule: 'alert rule saved successfully',
unknownMessageFromError: 'unknown messageFromError', unknownMessageFromError: 'unknown messageFromError',
grafanaRecording: 'creating Grafana recording rule from scratch',
loadedCentralAlertStateHistory: 'loaded central alert state history', loadedCentralAlertStateHistory: 'loaded central alert state history',
}; };

View File

@ -17,7 +17,10 @@ import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks'; import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id'; 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> = { const defaultPageNav: Partial<NavModelItem> = {
icon: 'bell', 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 // 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') => { const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting' | 'grafana-recording') => {
if (type === 'recording') { if (type === 'recording' || type === 'grafana-recording') {
if (identifier) { if (identifier) {
// this branch should never trigger actually, the type param isn't used when editing rules // 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' }; 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", "refId": "C",
}, },
], ],
"type": "grafana", "type": "grafana-alerting",
} }
`; `;

View File

@ -1,19 +1,25 @@
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors'; 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 { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { isCloudRecordingRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
const recordingRuleNameValidationPattern = { const recordingRuleNameValidationPattern = (type: RuleFormType) => ({
message: message: isGrafanaRecordingRuleByType(type)
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.', ? '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_:]*$/, 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 { const {
register, register,
watch, watch,
@ -21,8 +27,14 @@ export const AlertRuleNameInput = () => {
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const ruleFormType = watch('type'); 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 ( return (
<RuleEditorSection <RuleEditorSection
stepNo={1} stepNo={1}
@ -33,19 +45,37 @@ export const AlertRuleNameInput = () => {
</Text> </Text>
} }
> >
<Stack direction="column">
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}> <Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input <Input
data-testid={selectors.components.AlertRules.ruleNameField} data-testid={selectors.components.AlertRules.ruleNameField}
id="name" id="name"
width={35} width={38}
{...register('name', { {...register('name', {
required: { value: true, message: 'Must enter a name' }, required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined, pattern: isCloudRecordingRule
? recordingRuleNameValidationPattern(RuleFormType.cloudRecording)
: undefined,
})} })}
aria-label="name" aria-label="name"
placeholder={`Give your ${entityName} a name`} placeholder={`Give your ${entityName} a name`}
/> />
</Field> </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> </RuleEditorSection>
); );
}; };

View File

@ -5,6 +5,7 @@ import { Controller, RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui'; import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
import { isGrafanaAlertingRuleByType } from 'app/features/alerting/unified/utils/rules';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics'; import { LogMessages, logInfo } from '../../Analytics';
@ -290,6 +291,9 @@ export function GrafanaEvaluationBehavior({
const { watch, setValue } = useFormContext<RuleFormValues>(); const { watch, setValue } = useFormContext<RuleFormValues>();
const isPaused = watch('isPaused'); const isPaused = watch('isPaused');
const type = watch('type');
const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type);
return ( return (
// TODO remove "and alert condition" for recording rules // TODO remove "and alert condition" for recording rules
@ -300,7 +304,8 @@ export function GrafanaEvaluationBehavior({
evaluateEvery={evaluateEvery} evaluateEvery={evaluateEvery}
enableProvisionedGroups={enableProvisionedGroups} enableProvisionedGroups={enableProvisionedGroups}
/> />
<ForInput evaluateEvery={evaluateEvery} /> {/* Show the pending period input only for Grafana alerting rules */}
{isGrafanaAlertingRule && <ForInput evaluateEvery={evaluateEvery} />}
{existing && ( {existing && (
<Field htmlFor="pause-alert-switch"> <Field htmlFor="pause-alert-switch">
@ -327,6 +332,8 @@ export function GrafanaEvaluationBehavior({
</Field> </Field>
)} )}
</Stack> </Stack>
{isGrafanaAlertingRule && (
<>
<CollapseToggle <CollapseToggle
isCollapsed={!showErrorHandling} isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)} onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
@ -367,6 +374,8 @@ export function GrafanaEvaluationBehavior({
</Field> </Field>
</> </>
)} )}
</>
)}
</RuleEditorSection> </RuleEditorSection>
); );
} }

View File

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

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import * as React from 'react'; import * as React from 'react';
import { useCallback, useState } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useMountedState } from 'react-use'; import { useMountedState } from 'react-use';
import { takeWhile } from 'rxjs/operators'; import { takeWhile } from 'rxjs/operators';
@ -13,6 +13,7 @@ import { previewAlertRule } from '../../api/preview';
import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus'; import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus';
import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview'; import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview';
import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { isDataSourceManagedRuleByType } from '../../utils/rules';
import { PreviewRuleResult } from './PreviewRuleResult'; import { PreviewRuleResult } from './PreviewRuleResult';
@ -25,7 +26,7 @@ export function PreviewRule(): React.ReactElement | null {
const [type, condition, queries] = watch(['type', 'condition', 'queries']); const [type, condition, queries] = watch(['type', 'condition', 'queries']);
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
if (type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) { if (!type || isDataSourceManagedRuleByType(type)) {
return null; 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 InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import { import {
getRuleGroupLocationFromRuleWithLocation, getRuleGroupLocationFromRuleWithLocation,
isGrafanaManagedRuleByType,
isGrafanaRulerRule, isGrafanaRulerRule,
isGrafanaRulerRulePaused, isGrafanaRulerRulePaused,
isRecordingRuleByType,
} from 'app/features/alerting/unified/utils/rules'; } from 'app/features/alerting/unified/utils/rules';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting'; import { RuleWithLocation } from 'app/types/unified-alerting';
@ -23,8 +25,8 @@ import { RuleWithLocation } from 'app/types/unified-alerting';
import { import {
LogMessages, LogMessages,
logInfo, logInfo,
trackAlertRuleFormError,
trackAlertRuleFormCancelled, trackAlertRuleFormCancelled,
trackAlertRuleFormError,
trackAlertRuleFormSaved, trackAlertRuleFormSaved,
} from '../../../Analytics'; } from '../../../Analytics';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
@ -33,8 +35,8 @@ import { saveRuleFormAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { initialAsyncRequestState } from '../../../utils/redux'; import { initialAsyncRequestState } from '../../../utils/redux';
import { import {
MANUAL_ROUTING_KEY,
DEFAULT_GROUP_EVALUATION_INTERVAL, DEFAULT_GROUP_EVALUATION_INTERVAL,
MANUAL_ROUTING_KEY,
formValuesFromExistingRule, formValuesFromExistingRule,
getDefaultFormValues, getDefaultFormValues,
getDefaultQueries, getDefaultQueries,
@ -42,7 +44,7 @@ import {
normalizeDefaultAnnotations, normalizeDefaultAnnotations,
} from '../../../utils/rule-form'; } from '../../../utils/rule-form';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameInput } from '../AlertRuleNameInput'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep'; import AnnotationsStep from '../AnnotationsStep';
import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior'; import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
@ -106,7 +108,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const type = watch('type'); const type = watch('type');
const dataSourceName = watch('dataSourceName'); 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; const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
useCleanup((state) => (state.unifiedAlerting.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); const isPaused = existing && isGrafanaRulerRule(existing.rule) && isGrafanaRulerRulePaused(existing.rule);
if (!type) {
return null;
}
return ( return (
<FormProvider {...formAPI}> <FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} /> <AppChromeUpdate actions={actionButtons} />
@ -242,14 +247,14 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}> <CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<Stack direction="column" gap={3}> <Stack direction="column" gap={3}>
{/* Step 1 */} {/* Step 1 */}
<AlertRuleNameInput /> <AlertRuleNameAndMetric />
{/* Step 2 */} {/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={!!existing} onDataChange={checkAlertCondition} /> <QueryAndExpressionsStep editingExistingRule={!!existing} onDataChange={checkAlertCondition} />
{/* Step 3-4-5 */} {/* Step 3-4-5 */}
{showDataSourceDependantStep && ( {showDataSourceDependantStep && (
<> <>
{/* Step 3 */} {/* Step 3 */}
{type === RuleFormType.grafana && ( {isGrafanaManagedRuleByType(type) && (
<GrafanaEvaluationBehavior <GrafanaEvaluationBehavior
evaluateEvery={evaluateEvery} evaluateEvery={evaluateEvery}
setEvaluateEvery={setEvaluateEvery} setEvaluateEvery={setEvaluateEvery}
@ -266,7 +271,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
{/* Notifications step*/} {/* Notifications step*/}
<NotificationsStep alertUid={uidFromParams} /> <NotificationsStep alertUid={uidFromParams} />
{/* Annotations only for cloud and Grafana */} {/* Annotations only for cloud and Grafana */}
{type !== RuleFormType.cloudRecording && <AnnotationsStep />} {!isRecordingRuleByType(type) && <AnnotationsStep />}
</> </>
)} )}
</Stack> </Stack>
@ -285,7 +290,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
/> />
) : null} ) : null}
{showEditYaml ? ( {showEditYaml ? (
type === RuleFormType.grafana ? ( isGrafanaManagedRuleByType(type) ? (
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} /> <GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
) : ( ) : (
<RuleInspector 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 { isGrafanaRulerRule } from '../../../utils/rules';
import { FileExportPreview } from '../../export/FileExportPreview'; import { FileExportPreview } from '../../export/FileExportPreview';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
import { allGrafanaExportProviders, ExportFormats } from '../../export/providers'; import { ExportFormats, allGrafanaExportProviders } from '../../export/providers';
import { AlertRuleNameInput } from '../AlertRuleNameInput'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep'; import AnnotationsStep from '../AnnotationsStep';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
import { NotificationsStep } from '../NotificationsStep'; import { NotificationsStep } from '../NotificationsStep';
@ -88,7 +88,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}> <CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<Stack direction="column" gap={3}> <Stack direction="column" gap={3}>
{/* Step 1 */} {/* Step 1 */}
<AlertRuleNameInput /> <AlertRuleNameAndMetric />
{/* Step 2 */} {/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} /> <QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} />
{/* Step 3-4-5 */} {/* Step 3-4-5 */}

View File

@ -1,7 +1,7 @@
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { mockRulerGrafanaRule } from '../../../mocks'; import { mockRulerGrafanaRecordingRule, mockRulerGrafanaRule } from '../../../mocks';
import { RuleFormValues } from '../../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { Annotation } from '../../../utils/constants'; import { Annotation } from '../../../utils/constants';
import { getDefaultFormValues } from '../../../utils/rule-form'; import { getDefaultFormValues } from '../../../utils/rule-form';
@ -34,10 +34,19 @@ const rule3 = mockRulerGrafanaRule(
{ uid: 'uid-rule-3', title: 'Rule3', data: [] } { 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 // Prepare the form values for rule2 updated
const defaultValues = getDefaultFormValues(); const defaultValues = getDefaultFormValues();
const formValuesForRule2Updated: RuleFormValues = { const formValuesForRule2Updated: RuleFormValues = {
...defaultValues, ...defaultValues,
type: RuleFormType.grafana,
queries: [ queries: [
{ {
refId: 'A', refId: 'A',
@ -56,6 +65,26 @@ const formValuesForRule2Updated: RuleFormValues = {
labels: [{ key: 'newLabel', value: 'newLabel' }], labels: [{ key: 'newLabel', value: 'newLabel' }],
annotations: [{ key: 'summary', value: 'This grafana rule2 updated' }], 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) => ({ const expectedModifiedRule2 = (uid: string) => ({
annotations: { 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', () => { describe('getPayloadFromDto', () => {
const groupDto: RulerRuleGroupDTO<RulerRuleDTO> = { const groupDto: RulerRuleGroupDTO<RulerRuleDTO> = {
name: 'Test Group', 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', () => { 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); // for alerting rule
expect(result).toEqual({ const resultForAlerting = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto);
expect(resultForAlerting).toEqual({
name: 'Test Group', 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', () => { 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); const result = getPayloadToExport('uid-rule-5', formValuesForRule2Updated, groupDto);
expect(result).toEqual({ expect(result).toEqual({
name: 'Test Group', 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 { css } from '@emotion/css';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; 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 { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -18,6 +18,13 @@ import { fetchAllPromBuildInfoAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form'; import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form';
import {
isCloudAlertingRuleByType,
isCloudRecordingRuleByType,
isDataSourceManagedRuleByType,
isGrafanaAlertingRuleByType,
isGrafanaManagedRuleByType,
} from '../../../utils/rules';
import { ExpressionEditor } from '../ExpressionEditor'; import { ExpressionEditor } from '../ExpressionEditor';
import { ExpressionsEditor } from '../ExpressionsEditor'; import { ExpressionsEditor } from '../ExpressionsEditor';
import { NeedHelpInfo } from '../NeedHelpInfo'; import { NeedHelpInfo } from '../NeedHelpInfo';
@ -70,9 +77,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState); const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']); const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana; const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type);
const isRecordingRuleType = type === RuleFormType.cloudRecording; const isRecordingRuleType = isCloudRecordingRuleByType(type);
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting; const isCloudAlertRuleType = isCloudAlertingRuleByType(type);
const dispatchReduxAction = useDispatch(); const dispatchReduxAction = useDispatch();
useEffect(() => { useEffect(() => {
@ -114,9 +121,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const emptyQueries = queries.length === 0; const emptyQueries = queries.length === 0;
// apply some validations and asserts to the results of the evaluation when creating or editing // 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(() => { useEffect(() => {
if (!isGrafanaManagedType) { if (type && !isGrafanaManagedRuleByType(type)) {
return; return;
} }
@ -133,7 +140,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const error = errorFromPreviewData(previewData) ?? errorFromCurrentCondition(previewData); const error = errorFromPreviewData(previewData) ?? errorFromCurrentCondition(previewData);
onDataChange(error?.message || ''); onDataChange(error?.message || '');
}, [queryPreviewData, getValues, onDataChange, isGrafanaManagedType]); }, [queryPreviewData, getValues, onDataChange, type]);
const handleSetCondition = useCallback( const handleSetCondition = useCallback(
(refId: string | null) => { (refId: string | null) => {
@ -361,7 +368,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
]); ]);
const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana];
if (!type) {
return null;
}
return ( return (
<RuleEditorSection <RuleEditorSection
stepNo={2} stepNo={2}
@ -381,7 +390,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
} }
> >
{/* This is the cloud data source selector */} {/* This is the cloud data source selector */}
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && ( {isDataSourceManagedRuleByType(type) && (
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} /> <CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} />
)} )}
@ -429,8 +438,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
</Stack> </Stack>
)} )}
{/* This is the editor for Grafana managed rules */} {/* This is the editor for Grafana managed rules and Grafana managed recording rules */}
{isGrafanaManagedType && ( {isGrafanaManagedRuleByType(type) && (
<Stack direction="column"> <Stack direction="column">
{/* Data Queries */} {/* Data Queries */}
<QueryEditor <QueryEditor
@ -457,12 +466,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
Add query Add query
</Button> </Button>
</Tooltip> </Tooltip>
{/* We only show Switch for Grafana managed alerts */}
{isGrafanaAlertingType && (
<SmartAlertTypeDetector <SmartAlertTypeDetector
editingExistingRule={editingExistingRule} editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler} rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries} queries={queries}
onClickSwitch={onClickSwitch} onClickSwitch={onClickSwitch}
/> />
)}
{/* Expression Queries */} {/* Expression Queries */}
<Stack direction="column" gap={0}> <Stack direction="column" gap={0}>
<Text element="h5">Expressions</Text> <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.', 'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.',
helpLink: '', 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]: { [RuleFormType.grafana]: {
sectionTitle: 'Define query and alert condition', sectionTitle: 'Define query and alert condition',
helpLabel: '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; return RuleFormType.cloudRecording;
} }
if (param === 'grafana-recording') {
return RuleFormType.grafanaRecording;
}
return RuleFormType.grafana; return RuleFormType.grafana;
} }

View File

@ -20,6 +20,7 @@ import {
getRulePluginOrigin, getRulePluginOrigin,
isAlertingRule, isAlertingRule,
isFederatedRuleGroup, isFederatedRuleGroup,
isGrafanaRecordingRule,
isGrafanaRulerRule, isGrafanaRulerRule,
isGrafanaRulerRulePaused, isGrafanaRulerRulePaused,
isRecordingRule, 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) { if (interval) {
metadata.push({ metadata.push({

View File

@ -3,7 +3,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; 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 { CombinedRule } from 'app/types/unified-alerting';
import { Annotations } from 'app/types/unified-alerting-dto'; import { Annotations } from 'app/types/unified-alerting-dto';
@ -98,7 +98,10 @@ const Details = ({ rule }: DetailsProps) => {
</MetaText> </MetaText>
{/* nodata and execution error state mapping */} {/* nodata and execution error state mapping */}
{isGrafanaRulerRule(rule.rulerRule) && ( {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"> <MetaText direction="column">
Alert state if no data or all values are null Alert state if no data or all values are null

View File

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

View File

@ -4,24 +4,24 @@ import { useMemo } from 'react';
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; 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 { useAppNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { import {
useUpdateRuleGroupConfiguration,
useRenameRuleGroup,
useMoveRuleGroup, useMoveRuleGroup,
useRenameRuleGroup,
useUpdateRuleGroupConfiguration,
} from '../../hooks/ruleGroup/useUpdateRuleGroup'; } from '../../hooks/ruleGroup/useUpdateRuleGroup';
import { anyOfRequestState } from '../../hooks/useAsync'; import { anyOfRequestState } from '../../hooks/useAsync';
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions'; import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; 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 { stringifyErrorLike } from '../../utils/misc';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; 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 { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
@ -75,7 +75,8 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou
})) }))
.sort( .sort(
(alert1, alert2) => (alert1, alert2) =>
safeParsePrometheusDuration(alert1.data.forDuration) - safeParsePrometheusDuration(alert2.data.forDuration) safeParsePrometheusDuration(alert1.data.forDuration ?? '') -
safeParsePrometheusDuration(alert2.data.forDuration ?? '')
); );
const columns: AlertsWithForTableColumnProps[] = useMemo(() => { const columns: AlertsWithForTableColumnProps[] = useMemo(() => {
@ -154,9 +155,11 @@ export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterO
} else { } else {
const rulePendingPeriods = rules.map((rule) => { const rulePendingPeriods = rules.map((rule) => {
const { forDuration } = getAlertInfo(rule, evaluateEvery); 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)}".`; 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) { } catch (error) {
@ -262,7 +265,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
}; };
const rulesWithoutRecordingRules = compact( 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 hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
const modalTitle = const modalTitle =

View File

@ -2,11 +2,14 @@ import { css } from '@emotion/css';
import { useToggle } from 'react-use'; import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data'; 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 { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans, t } from 'app/core/internationalization';
import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { LogMessages, logInfo } from '../../Analytics';
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities'; import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces'; import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces';
import { usePagination } from '../../hooks/usePagination'; import { usePagination } from '../../hooks/usePagination';
@ -14,6 +17,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
import { getPaginationStyles } from '../../styles/pagination'; import { getPaginationStyles } from '../../styles/pagination';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux'; import { initialAsyncRequestState } from '../../utils/redux';
import { createUrl } from '../../utils/url';
import { GrafanaRulesExporter } from '../export/GrafanaRulesExporter'; import { GrafanaRulesExporter } from '../export/GrafanaRulesExporter';
import { RulesGroup } from './RulesGroup'; import { RulesGroup } from './RulesGroup';
@ -53,14 +57,21 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => {
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const hasGrafanaAlerts = namespaces.length > 0; const hasGrafanaAlerts = namespaces.length > 0;
const grafanaRecordingRulesEnabled = config.featureToggles.grafanaManagedRecordingRules;
return ( return (
<section className={styles.wrapper}> <section className={styles.wrapper}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div className={styles.headerRow}> <div className={styles.headerRow}>
<Text element="h2" variant="h5"> <Text element="h2" variant="h5">
Grafana <Trans i18nKey="alerting.grafana-rules.title">Grafana</Trans>
</Text> </Text>
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />} {loading ? (
<LoadingPlaceholder className={styles.loader} text={t('alerting.grafana-rules.loading', 'Loading...')} />
) : (
<div />
)}
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{hasGrafanaAlerts && canExportRules && ( {hasGrafanaAlerts && canExportRules && (
<Button <Button
aria-label="export all grafana rules" aria-label="export all grafana rules"
@ -70,9 +81,23 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => {
onClick={toggleShowExportDrawer} onClick={toggleShowExportDrawer}
variant="secondary" variant="secondary"
> >
Export rules <Trans i18nKey="alerting.grafana-rules.export-rules">Export rules</Trans>
</Button> </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>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -2,11 +2,13 @@ import { css } from '@emotion/css';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data'; 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 { CombinedRule } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; 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'; import { AlertStateTag } from './AlertStateTag';
@ -19,9 +21,22 @@ interface Props {
export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) => { export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) => {
const style = useStyles2(getStyle); const style = useStyles2(getStyle);
const { promRule } = rule; const { promRule, rulerRule } = rule;
// return how long the rule has been in its firing state, if any // 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(() => { const forTime = useMemo(() => {
if ( if (
promRule && promRule &&
@ -55,14 +70,14 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
return ( return (
<Stack gap={1}> <Stack gap={1}>
<Spinner /> <Spinner />
Deleting <Trans i18nKey="alerting.rule-state.deleting">Deleting</Trans>
</Stack> </Stack>
); );
} else if (isCreating) { } else if (isCreating) {
return ( return (
<Stack gap={1}> <Stack gap={1}>
<Spinner /> <Spinner />
Creating <Trans i18nKey="alerting.rule-state.creating">Creating</Trans>
</Stack> </Stack>
); );
} else if (promRule && isAlertingRule(promRule)) { } else if (promRule && isAlertingRule(promRule)) {
@ -73,7 +88,7 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
</Stack> </Stack>
); );
} else if (promRule && isRecordingRule(promRule)) { } else if (promRule && isRecordingRule(promRule)) {
return <>Recording rule</>; return <RecordingRuleState />;
} }
return <>n/a</>; return <>n/a</>;
}; };

View File

@ -11,7 +11,7 @@ import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { isAdmin } from '../utils/misc'; import { isAdmin } from '../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
import { useIsRuleEditable } from './useIsRuleEditable'; import { useIsRuleEditable } from './useIsRuleEditable';
@ -284,6 +284,7 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
function useCanSilence(rule: CombinedRule): [boolean, boolean] { function useCanSilence(rule: CombinedRule): [boolean, boolean] {
const rulesSource = rule.namespace.rulesSource; const rulesSource = rule.namespace.rulesSource;
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME; const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
const isGrafanaRecording = isGrafanaRecordingRule(rule.rulerRule);
const { currentData: amConfigStatus, isLoading } = const { currentData: amConfigStatus, isLoading } =
alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, { 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 folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID); 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 // 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]; return [false, false];
} }

View File

@ -142,6 +142,42 @@ export const mockRulerGrafanaRule = (
...partial, ...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 => ({ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {}): RulerAlertingRuleDTO => ({
alert: 'alert1', alert: 'alert1',

View File

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

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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": { "annotations": {
"description": "", "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`] = ` exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = `
{ {
"annotations": { "annotations": {

View File

@ -20,7 +20,7 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null
if (isGrafanaRulerRule(rulerRule)) { if (isGrafanaRulerRule(rulerRule)) {
const query = rulerRule.grafana_alert.data; 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)) { if (isCloudRulesSource(rulesSource)) {

View File

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

View File

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

View File

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

View File

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

View File

@ -112,6 +112,12 @@
"used-by_one": "Used by {{ count }} notification policy", "used-by_one": "Used by {{ count }} notification policy",
"used-by_other": "Used by {{ count }} notification policies" "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": { "policies": {
"metadata": { "metadata": {
"timingOptions": { "timingOptions": {
@ -144,6 +150,12 @@
"success": "Successfully updated rule group" "success": "Successfully updated rule group"
} }
}, },
"rule-state": {
"creating": "Creating",
"deleting": "Deleting",
"paused": "Paused",
"recording-rule": "Recording rule"
},
"rules": { "rules": {
"delete-rule": { "delete-rule": {
"success": "Rule successfully deleted" "success": "Rule successfully deleted"

View File

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