mirror of
https://github.com/grafana/grafana.git
synced 2024-11-26 02:40:26 -06:00
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:
parent
c0af387766
commit
8423d06988
@ -2280,9 +2280,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/GrafanaRules.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
@ -2328,10 +2326,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
|
@ -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',
|
||||
};
|
||||
|
||||
|
@ -17,7 +17,10 @@ import { fetchRulesSourceBuildInfoAction } from './state/actions';
|
||||
import { useRulesAccess } from './utils/accessControlHooks';
|
||||
import * as ruleId from './utils/rule-id';
|
||||
|
||||
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string; type?: 'recording' | 'alerting' }>;
|
||||
type RuleEditorProps = GrafanaRouteComponentProps<{
|
||||
id?: string;
|
||||
type?: 'recording' | 'alerting' | 'grafana-recording';
|
||||
}>;
|
||||
|
||||
const defaultPageNav: Partial<NavModelItem> = {
|
||||
icon: 'bell',
|
||||
@ -25,8 +28,8 @@ const defaultPageNav: Partial<NavModelItem> = {
|
||||
};
|
||||
|
||||
// sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL
|
||||
const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting') => {
|
||||
if (type === 'recording') {
|
||||
const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting' | 'grafana-recording') => {
|
||||
if (type === 'recording' || type === 'grafana-recording') {
|
||||
if (identifier) {
|
||||
// this branch should never trigger actually, the type param isn't used when editing rules
|
||||
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' };
|
||||
|
@ -112,6 +112,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
"type": "grafana",
|
||||
"type": "grafana-alerting",
|
||||
}
|
||||
`;
|
||||
|
@ -1,19 +1,25 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Field, Input, Text } from '@grafana/ui';
|
||||
import { Field, Input, Stack, Text } from '@grafana/ui';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { isCloudRecordingRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules';
|
||||
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
|
||||
const recordingRuleNameValidationPattern = {
|
||||
message:
|
||||
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
|
||||
const recordingRuleNameValidationPattern = (type: RuleFormType) => ({
|
||||
message: isGrafanaRecordingRuleByType(type)
|
||||
? 'Recording rule metric must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.'
|
||||
: 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
|
||||
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
|
||||
};
|
||||
});
|
||||
|
||||
export const AlertRuleNameInput = () => {
|
||||
/**
|
||||
* This component renders the input for the alert rule name.
|
||||
* In case of recording rule, it also renders the input for the recording rule metric, and it validates this value.
|
||||
*/
|
||||
export const AlertRuleNameAndMetric = () => {
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
@ -21,8 +27,14 @@ export const AlertRuleNameInput = () => {
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const ruleFormType = watch('type');
|
||||
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
|
||||
|
||||
if (!ruleFormType) {
|
||||
return null;
|
||||
}
|
||||
const isRecording = isRecordingRuleByType(ruleFormType);
|
||||
const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(ruleFormType);
|
||||
const isCloudRecordingRule = isCloudRecordingRuleByType(ruleFormType);
|
||||
const recordingLabel = isGrafanaRecordingRule ? 'recording rule and metric' : 'recording rule';
|
||||
const entityName = isRecording ? recordingLabel : 'alert rule';
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={1}
|
||||
@ -33,19 +45,37 @@ export const AlertRuleNameInput = () => {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
|
||||
<Input
|
||||
data-testid={selectors.components.AlertRules.ruleNameField}
|
||||
id="name"
|
||||
width={35}
|
||||
{...register('name', {
|
||||
required: { value: true, message: 'Must enter a name' },
|
||||
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
|
||||
})}
|
||||
aria-label="name"
|
||||
placeholder={`Give your ${entityName} a name`}
|
||||
/>
|
||||
</Field>
|
||||
<Stack direction="column">
|
||||
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
|
||||
<Input
|
||||
data-testid={selectors.components.AlertRules.ruleNameField}
|
||||
id="name"
|
||||
width={38}
|
||||
{...register('name', {
|
||||
required: { value: true, message: 'Must enter a name' },
|
||||
pattern: isCloudRecordingRule
|
||||
? recordingRuleNameValidationPattern(RuleFormType.cloudRecording)
|
||||
: undefined,
|
||||
})}
|
||||
aria-label="name"
|
||||
placeholder={`Give your ${entityName} a name`}
|
||||
/>
|
||||
</Field>
|
||||
{isGrafanaRecordingRule && (
|
||||
<Field label="Metric" error={errors?.metric?.message} invalid={!!errors.metric?.message}>
|
||||
<Input
|
||||
id="metric"
|
||||
width={38}
|
||||
{...register('metric', {
|
||||
required: { value: true, message: 'Must enter a metric name' },
|
||||
pattern: recordingRuleNameValidationPattern(RuleFormType.grafanaRecording),
|
||||
})}
|
||||
aria-label="metric"
|
||||
placeholder={`Give your metric a name`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Stack>
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import { Controller, RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { isGrafanaAlertingRuleByType } from 'app/features/alerting/unified/utils/rules';
|
||||
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
|
||||
import { LogMessages, logInfo } from '../../Analytics';
|
||||
@ -290,6 +291,9 @@ export function GrafanaEvaluationBehavior({
|
||||
const { watch, setValue } = useFormContext<RuleFormValues>();
|
||||
|
||||
const isPaused = watch('isPaused');
|
||||
const type = watch('type');
|
||||
|
||||
const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type);
|
||||
|
||||
return (
|
||||
// TODO remove "and alert condition" for recording rules
|
||||
@ -300,7 +304,8 @@ export function GrafanaEvaluationBehavior({
|
||||
evaluateEvery={evaluateEvery}
|
||||
enableProvisionedGroups={enableProvisionedGroups}
|
||||
/>
|
||||
<ForInput evaluateEvery={evaluateEvery} />
|
||||
{/* Show the pending period input only for Grafana alerting rules */}
|
||||
{isGrafanaAlertingRule && <ForInput evaluateEvery={evaluateEvery} />}
|
||||
|
||||
{existing && (
|
||||
<Field htmlFor="pause-alert-switch">
|
||||
@ -327,44 +332,48 @@ export function GrafanaEvaluationBehavior({
|
||||
</Field>
|
||||
)}
|
||||
</Stack>
|
||||
<CollapseToggle
|
||||
isCollapsed={!showErrorHandling}
|
||||
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
|
||||
text="Configure no data and error handling"
|
||||
/>
|
||||
{showErrorHandling && (
|
||||
{isGrafanaAlertingRule && (
|
||||
<>
|
||||
<NeedHelpInfoForConfigureNoDataError />
|
||||
<Field htmlFor="no-data-state-input" label="Alert state if no data or all values are null">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<GrafanaAlertStatePicker
|
||||
{...field}
|
||||
inputId="no-data-state-input"
|
||||
width={42}
|
||||
includeNoData={true}
|
||||
includeError={false}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
<CollapseToggle
|
||||
isCollapsed={!showErrorHandling}
|
||||
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
|
||||
text="Configure no data and error handling"
|
||||
/>
|
||||
{showErrorHandling && (
|
||||
<>
|
||||
<NeedHelpInfoForConfigureNoDataError />
|
||||
<Field htmlFor="no-data-state-input" label="Alert state if no data or all values are null">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<GrafanaAlertStatePicker
|
||||
{...field}
|
||||
inputId="no-data-state-input"
|
||||
width={42}
|
||||
includeNoData={true}
|
||||
includeError={false}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
/>
|
||||
)}
|
||||
name="noDataState"
|
||||
/>
|
||||
)}
|
||||
name="noDataState"
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="exec-err-state-input" label="Alert state if execution error or timeout">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<GrafanaAlertStatePicker
|
||||
{...field}
|
||||
inputId="exec-err-state-input"
|
||||
width={42}
|
||||
includeNoData={false}
|
||||
includeError={true}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
</Field>
|
||||
<Field htmlFor="exec-err-state-input" label="Alert state if execution error or timeout">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<GrafanaAlertStatePicker
|
||||
{...field}
|
||||
inputId="exec-err-state-input"
|
||||
width={42}
|
||||
includeNoData={false}
|
||||
includeError={true}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
/>
|
||||
)}
|
||||
name="execErrState"
|
||||
/>
|
||||
)}
|
||||
name="execErrState"
|
||||
/>
|
||||
</Field>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
|
@ -10,6 +10,7 @@ import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { isRecordingRuleByType } from '../../utils/rules';
|
||||
|
||||
import { NeedHelpInfo } from './NeedHelpInfo';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
@ -62,11 +63,14 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
|
||||
}
|
||||
setShowLabelsEditor(false);
|
||||
}
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={4}
|
||||
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure labels and notifications'}
|
||||
title={isRecordingRuleByType(type) ? 'Add labels' : 'Configure labels and notifications'}
|
||||
description={
|
||||
<Stack direction="row" gap={0.5} alignItems="center">
|
||||
{type === RuleFormType.cloudRecording ? (
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -14,8 +14,10 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
|
||||
import {
|
||||
getRuleGroupLocationFromRuleWithLocation,
|
||||
isGrafanaManagedRuleByType,
|
||||
isGrafanaRulerRule,
|
||||
isGrafanaRulerRulePaused,
|
||||
isRecordingRuleByType,
|
||||
} from 'app/features/alerting/unified/utils/rules';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
@ -23,8 +25,8 @@ import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import {
|
||||
LogMessages,
|
||||
logInfo,
|
||||
trackAlertRuleFormError,
|
||||
trackAlertRuleFormCancelled,
|
||||
trackAlertRuleFormError,
|
||||
trackAlertRuleFormSaved,
|
||||
} from '../../../Analytics';
|
||||
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
||||
@ -33,8 +35,8 @@ import { saveRuleFormAction } from '../../../state/actions';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { initialAsyncRequestState } from '../../../utils/redux';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
MANUAL_ROUTING_KEY,
|
||||
formValuesFromExistingRule,
|
||||
getDefaultFormValues,
|
||||
getDefaultQueries,
|
||||
@ -42,7 +44,7 @@ import {
|
||||
normalizeDefaultAnnotations,
|
||||
} from '../../../utils/rule-form';
|
||||
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
|
||||
import { AlertRuleNameInput } from '../AlertRuleNameInput';
|
||||
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
|
||||
import AnnotationsStep from '../AnnotationsStep';
|
||||
import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior';
|
||||
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
|
||||
@ -106,7 +108,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
const type = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
|
||||
const showDataSourceDependantStep = Boolean(type && (type === RuleFormType.grafana || !!dataSourceName));
|
||||
const showDataSourceDependantStep = Boolean(type && (isGrafanaManagedRuleByType(type) || !!dataSourceName));
|
||||
|
||||
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
|
||||
useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState));
|
||||
@ -233,6 +235,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
);
|
||||
|
||||
const isPaused = existing && isGrafanaRulerRule(existing.rule) && isGrafanaRulerRulePaused(existing.rule);
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<AppChromeUpdate actions={actionButtons} />
|
||||
@ -242,14 +247,14 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 */}
|
||||
<AlertRuleNameInput />
|
||||
<AlertRuleNameAndMetric />
|
||||
{/* Step 2 */}
|
||||
<QueryAndExpressionsStep editingExistingRule={!!existing} onDataChange={checkAlertCondition} />
|
||||
{/* Step 3-4-5 */}
|
||||
{showDataSourceDependantStep && (
|
||||
<>
|
||||
{/* Step 3 */}
|
||||
{type === RuleFormType.grafana && (
|
||||
{isGrafanaManagedRuleByType(type) && (
|
||||
<GrafanaEvaluationBehavior
|
||||
evaluateEvery={evaluateEvery}
|
||||
setEvaluateEvery={setEvaluateEvery}
|
||||
@ -266,7 +271,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
{/* Notifications step*/}
|
||||
<NotificationsStep alertUid={uidFromParams} />
|
||||
{/* Annotations only for cloud and Grafana */}
|
||||
{type !== RuleFormType.cloudRecording && <AnnotationsStep />}
|
||||
{!isRecordingRuleByType(type) && <AnnotationsStep />}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
@ -285,7 +290,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
/>
|
||||
) : null}
|
||||
{showEditYaml ? (
|
||||
type === RuleFormType.grafana ? (
|
||||
isGrafanaManagedRuleByType(type) ? (
|
||||
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
|
||||
) : (
|
||||
<RuleInspector onClose={() => setShowEditYaml(false)} />
|
||||
|
@ -21,8 +21,8 @@ import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } fr
|
||||
import { isGrafanaRulerRule } from '../../../utils/rules';
|
||||
import { FileExportPreview } from '../../export/FileExportPreview';
|
||||
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
|
||||
import { allGrafanaExportProviders, ExportFormats } from '../../export/providers';
|
||||
import { AlertRuleNameInput } from '../AlertRuleNameInput';
|
||||
import { ExportFormats, allGrafanaExportProviders } from '../../export/providers';
|
||||
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
|
||||
import AnnotationsStep from '../AnnotationsStep';
|
||||
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
|
||||
import { NotificationsStep } from '../NotificationsStep';
|
||||
@ -88,7 +88,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
||||
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 */}
|
||||
<AlertRuleNameInput />
|
||||
<AlertRuleNameAndMetric />
|
||||
{/* Step 2 */}
|
||||
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} />
|
||||
{/* Step 3-4-5 */}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { mockRulerGrafanaRule } from '../../../mocks';
|
||||
import { RuleFormValues } from '../../../types/rule-form';
|
||||
import { mockRulerGrafanaRecordingRule, mockRulerGrafanaRule } from '../../../mocks';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { Annotation } from '../../../utils/constants';
|
||||
import { getDefaultFormValues } from '../../../utils/rule-form';
|
||||
|
||||
@ -34,10 +34,19 @@ const rule3 = mockRulerGrafanaRule(
|
||||
{ uid: 'uid-rule-3', title: 'Rule3', data: [] }
|
||||
);
|
||||
|
||||
const rule4 = mockRulerGrafanaRecordingRule(
|
||||
{
|
||||
labels: { severity: 'notcritical4', region: 'region4' },
|
||||
annotations: { [Annotation.summary]: 'This grafana rule4' },
|
||||
},
|
||||
{ uid: 'uid-rule-4', title: 'Rule4', data: [] }
|
||||
);
|
||||
|
||||
// Prepare the form values for rule2 updated
|
||||
const defaultValues = getDefaultFormValues();
|
||||
const formValuesForRule2Updated: RuleFormValues = {
|
||||
...defaultValues,
|
||||
type: RuleFormType.grafana,
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
@ -56,6 +65,26 @@ const formValuesForRule2Updated: RuleFormValues = {
|
||||
labels: [{ key: 'newLabel', value: 'newLabel' }],
|
||||
annotations: [{ key: 'summary', value: 'This grafana rule2 updated' }],
|
||||
};
|
||||
const formValuesForRecordingRule4Updated: RuleFormValues = {
|
||||
...defaultValues,
|
||||
type: RuleFormType.grafanaRecording,
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
relativeTimeRange: { from: 900, to: 1000 },
|
||||
datasourceUid: 'dsuid',
|
||||
model: {
|
||||
refId: 'A',
|
||||
hide: true,
|
||||
},
|
||||
queryType: 'query',
|
||||
},
|
||||
],
|
||||
condition: 'A',
|
||||
name: 'Rule4 updated',
|
||||
labels: [{ key: 'newLabel', value: 'newLabel' }],
|
||||
annotations: [{ key: 'summary', value: 'This grafana rule4 updated' }],
|
||||
};
|
||||
|
||||
const expectedModifiedRule2 = (uid: string) => ({
|
||||
annotations: {
|
||||
@ -90,24 +119,81 @@ const expectedModifiedRule2 = (uid: string) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const expectedModifiedRule4 = (uid: string) => ({
|
||||
annotations: {
|
||||
summary: 'This grafana rule4 updated',
|
||||
},
|
||||
grafana_alert: {
|
||||
condition: 'A',
|
||||
data: [
|
||||
{
|
||||
datasourceUid: 'dsuid',
|
||||
model: {
|
||||
refId: 'A',
|
||||
hide: true,
|
||||
},
|
||||
queryType: 'query',
|
||||
refId: 'A',
|
||||
relativeTimeRange: {
|
||||
from: 900,
|
||||
to: 1000,
|
||||
},
|
||||
},
|
||||
],
|
||||
is_paused: false,
|
||||
notification_settings: undefined,
|
||||
record: {
|
||||
metric: 'Rule4 updated',
|
||||
from: 'A',
|
||||
},
|
||||
title: 'Rule4 updated',
|
||||
uid: uid,
|
||||
},
|
||||
labels: {
|
||||
newLabel: 'newLabel',
|
||||
},
|
||||
});
|
||||
|
||||
describe('getPayloadFromDto', () => {
|
||||
const groupDto: RulerRuleGroupDTO<RulerRuleDTO> = {
|
||||
name: 'Test Group',
|
||||
rules: [rule1, rule2, rule3],
|
||||
rules: [rule1, rule2, rule3, rule4],
|
||||
};
|
||||
|
||||
it('should return a ModifyExportPayload with the updated rule added to a group with this rule belongs, in the same position', () => {
|
||||
const result = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto);
|
||||
expect(result).toEqual({
|
||||
// for alerting rule
|
||||
const resultForAlerting = getPayloadToExport('uid-rule-2', formValuesForRule2Updated, groupDto);
|
||||
expect(resultForAlerting).toEqual({
|
||||
name: 'Test Group',
|
||||
rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3],
|
||||
rules: [rule1, expectedModifiedRule2('uid-rule-2'), rule3, rule4],
|
||||
});
|
||||
// for recording rule
|
||||
const resultForRecording = getPayloadToExport(
|
||||
'uid-rule-4',
|
||||
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
|
||||
groupDto
|
||||
);
|
||||
expect(resultForRecording).toEqual({
|
||||
name: 'Test Group',
|
||||
rules: [rule1, rule2, rule3, expectedModifiedRule4('uid-rule-4')],
|
||||
});
|
||||
});
|
||||
it('should return a ModifyExportPayload with the updated rule added to a non empty rule where this rule does not belong, in the last position', () => {
|
||||
// for alerting rule
|
||||
const result = getPayloadToExport('uid-rule-5', formValuesForRule2Updated, groupDto);
|
||||
expect(result).toEqual({
|
||||
name: 'Test Group',
|
||||
rules: [rule1, rule2, rule3, expectedModifiedRule2('uid-rule-5')],
|
||||
rules: [rule1, rule2, rule3, rule4, expectedModifiedRule2('uid-rule-5')],
|
||||
});
|
||||
// for recording rule
|
||||
const resultForRecording = getPayloadToExport(
|
||||
'uid-rule-5',
|
||||
{ ...formValuesForRecordingRule4Updated, type: RuleFormType.grafanaRecording },
|
||||
groupDto
|
||||
);
|
||||
expect(resultForRecording).toEqual({
|
||||
name: 'Test Group',
|
||||
rules: [rule1, rule2, rule3, rule4, expectedModifiedRule4('uid-rule-5')],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@ -18,6 +18,13 @@ import { fetchAllPromBuildInfoAction } from '../../../state/actions';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
|
||||
import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form';
|
||||
import {
|
||||
isCloudAlertingRuleByType,
|
||||
isCloudRecordingRuleByType,
|
||||
isDataSourceManagedRuleByType,
|
||||
isGrafanaAlertingRuleByType,
|
||||
isGrafanaManagedRuleByType,
|
||||
} from '../../../utils/rules';
|
||||
import { ExpressionEditor } from '../ExpressionEditor';
|
||||
import { ExpressionsEditor } from '../ExpressionsEditor';
|
||||
import { NeedHelpInfo } from '../NeedHelpInfo';
|
||||
@ -70,9 +77,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
|
||||
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
|
||||
|
||||
const isGrafanaManagedType = type === RuleFormType.grafana;
|
||||
const isRecordingRuleType = type === RuleFormType.cloudRecording;
|
||||
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
|
||||
const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type);
|
||||
const isRecordingRuleType = isCloudRecordingRuleByType(type);
|
||||
const isCloudAlertRuleType = isCloudAlertingRuleByType(type);
|
||||
|
||||
const dispatchReduxAction = useDispatch();
|
||||
useEffect(() => {
|
||||
@ -114,9 +121,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
const emptyQueries = queries.length === 0;
|
||||
|
||||
// apply some validations and asserts to the results of the evaluation when creating or editing
|
||||
// Grafana-managed alert rules
|
||||
// Grafana-managed alert rules and Grafa-managed recording rules
|
||||
useEffect(() => {
|
||||
if (!isGrafanaManagedType) {
|
||||
if (type && !isGrafanaManagedRuleByType(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -133,7 +140,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
const error = errorFromPreviewData(previewData) ?? errorFromCurrentCondition(previewData);
|
||||
|
||||
onDataChange(error?.message || '');
|
||||
}, [queryPreviewData, getValues, onDataChange, isGrafanaManagedType]);
|
||||
}, [queryPreviewData, getValues, onDataChange, type]);
|
||||
|
||||
const handleSetCondition = useCallback(
|
||||
(refId: string | null) => {
|
||||
@ -361,7 +368,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
]);
|
||||
|
||||
const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana];
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={2}
|
||||
@ -381,7 +390,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
}
|
||||
>
|
||||
{/* This is the cloud data source selector */}
|
||||
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && (
|
||||
{isDataSourceManagedRuleByType(type) && (
|
||||
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} />
|
||||
)}
|
||||
|
||||
@ -429,8 +438,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* This is the editor for Grafana managed rules */}
|
||||
{isGrafanaManagedType && (
|
||||
{/* This is the editor for Grafana managed rules and Grafana managed recording rules */}
|
||||
{isGrafanaManagedRuleByType(type) && (
|
||||
<Stack direction="column">
|
||||
{/* Data Queries */}
|
||||
<QueryEditor
|
||||
@ -457,12 +466,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
|
||||
Add query
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<SmartAlertTypeDetector
|
||||
editingExistingRule={editingExistingRule}
|
||||
rulesSourcesWithRuler={rulesSourcesWithRuler}
|
||||
queries={queries}
|
||||
onClickSwitch={onClickSwitch}
|
||||
/>
|
||||
{/* We only show Switch for Grafana managed alerts */}
|
||||
{isGrafanaAlertingType && (
|
||||
<SmartAlertTypeDetector
|
||||
editingExistingRule={editingExistingRule}
|
||||
rulesSourcesWithRuler={rulesSourcesWithRuler}
|
||||
queries={queries}
|
||||
onClickSwitch={onClickSwitch}
|
||||
/>
|
||||
)}
|
||||
{/* Expression Queries */}
|
||||
<Stack direction="column" gap={0}>
|
||||
<Text element="h5">Expressions</Text>
|
||||
|
@ -15,6 +15,13 @@ export const DESCRIPTIONS: Record<RuleFormType, FormDescriptions> = {
|
||||
'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.',
|
||||
helpLink: '',
|
||||
},
|
||||
[RuleFormType.grafanaRecording]: {
|
||||
sectionTitle: 'Define recording rule',
|
||||
helpLabel: 'Define your recording rule',
|
||||
helpContent:
|
||||
'Pre-compute frequently needed or computationally expensive expressions and save their result as a new set of time series.',
|
||||
helpLink: '',
|
||||
},
|
||||
[RuleFormType.grafana]: {
|
||||
sectionTitle: 'Define query and alert condition',
|
||||
helpLabel: 'Define query and alert condition',
|
||||
|
@ -327,6 +327,10 @@ export function translateRouteParamToRuleType(param = ''): RuleFormType {
|
||||
return RuleFormType.cloudRecording;
|
||||
}
|
||||
|
||||
if (param === 'grafana-recording') {
|
||||
return RuleFormType.grafanaRecording;
|
||||
}
|
||||
|
||||
return RuleFormType.grafana;
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
getRulePluginOrigin,
|
||||
isAlertingRule,
|
||||
isFederatedRuleGroup,
|
||||
isGrafanaRecordingRule,
|
||||
isGrafanaRulerRule,
|
||||
isGrafanaRulerRulePaused,
|
||||
isRecordingRule,
|
||||
@ -192,6 +193,13 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isGrafanaRecordingRule(rule.rulerRule)) {
|
||||
const metric = rule.rulerRule?.grafana_alert.record?.metric ?? '';
|
||||
metadata.push({
|
||||
label: 'Metric name',
|
||||
value: <Text color="primary">{metric}</Text>,
|
||||
});
|
||||
}
|
||||
|
||||
if (interval) {
|
||||
metadata.push({
|
||||
|
@ -3,7 +3,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Text, Stack, useStyles2, ClipboardButton, TextLink } from '@grafana/ui';
|
||||
import { ClipboardButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { Annotations } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@ -98,18 +98,21 @@ const Details = ({ rule }: DetailsProps) => {
|
||||
</MetaText>
|
||||
|
||||
{/* nodata and execution error state mapping */}
|
||||
{isGrafanaRulerRule(rule.rulerRule) && (
|
||||
<>
|
||||
<MetaText direction="column">
|
||||
Alert state if no data or all values are null
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
|
||||
</MetaText>
|
||||
<MetaText direction="column">
|
||||
Alert state if execution error or timeout
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
{isGrafanaRulerRule(rule.rulerRule) &&
|
||||
// grafana recording rules don't have these fields
|
||||
rule.rulerRule.grafana_alert.no_data_state &&
|
||||
rule.rulerRule.grafana_alert.exec_err_state && (
|
||||
<>
|
||||
<MetaText direction="column">
|
||||
Alert state if no data or all values are null
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
|
||||
</MetaText>
|
||||
<MetaText direction="column">
|
||||
Alert state if execution error or timeout
|
||||
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
|
||||
</MetaText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* annotations go here */}
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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 =
|
||||
|
@ -2,11 +2,14 @@ import { css } from '@emotion/css';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, LoadingPlaceholder, Pagination, Spinner, useStyles2, Text } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, LinkButton, LoadingPlaceholder, Pagination, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
|
||||
import { LogMessages, logInfo } from '../../Analytics';
|
||||
import { AlertingAction, useAlertingAbility } from '../../hooks/useAbilities';
|
||||
import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
@ -14,6 +17,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
|
||||
import { getPaginationStyles } from '../../styles/pagination';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { initialAsyncRequestState } from '../../utils/redux';
|
||||
import { createUrl } from '../../utils/url';
|
||||
import { GrafanaRulesExporter } from '../export/GrafanaRulesExporter';
|
||||
|
||||
import { RulesGroup } from './RulesGroup';
|
||||
@ -53,26 +57,47 @@ export const GrafanaRules = ({ namespaces, expandAll }: Props) => {
|
||||
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
|
||||
const hasGrafanaAlerts = namespaces.length > 0;
|
||||
|
||||
const grafanaRecordingRulesEnabled = config.featureToggles.grafanaManagedRecordingRules;
|
||||
|
||||
return (
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text element="h2" variant="h5">
|
||||
Grafana
|
||||
<Trans i18nKey="alerting.grafana-rules.title">Grafana</Trans>
|
||||
</Text>
|
||||
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />}
|
||||
{hasGrafanaAlerts && canExportRules && (
|
||||
<Button
|
||||
aria-label="export all grafana rules"
|
||||
data-testid="export-all-grafana-rules"
|
||||
icon="download-alt"
|
||||
tooltip="Export all Grafana-managed rules"
|
||||
onClick={toggleShowExportDrawer}
|
||||
variant="secondary"
|
||||
>
|
||||
Export rules
|
||||
</Button>
|
||||
{loading ? (
|
||||
<LoadingPlaceholder className={styles.loader} text={t('alerting.grafana-rules.loading', 'Loading...')} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
{hasGrafanaAlerts && canExportRules && (
|
||||
<Button
|
||||
aria-label="export all grafana rules"
|
||||
data-testid="export-all-grafana-rules"
|
||||
icon="download-alt"
|
||||
tooltip="Export all Grafana-managed rules"
|
||||
onClick={toggleShowExportDrawer}
|
||||
variant="secondary"
|
||||
>
|
||||
<Trans i18nKey="alerting.grafana-rules.export-rules">Export rules</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{grafanaRecordingRulesEnabled && (
|
||||
<LinkButton
|
||||
href={createUrl('/alerting/new/grafana-recording', {
|
||||
returnTo: '/alerting/list' + location.search,
|
||||
})}
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
tooltip="Create new Grafana-managed recording rule"
|
||||
onClick={() => logInfo(LogMessages.grafanaRecording)}
|
||||
>
|
||||
<Trans i18nKey="alerting.grafana-rules.new-recording-rule">New recording rule</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LinkButton, useStyles2, Stack } from '@grafana/ui';
|
||||
import { LinkButton, Stack, useStyles2 } from '@grafana/ui';
|
||||
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
|
||||
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
|
||||
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
|
||||
@ -18,7 +18,7 @@ import { fetchPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
|
||||
import { createViewLink } from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { createUrl } from '../../utils/url';
|
||||
|
||||
import { RedirectToCloneRule } from './CloneRule';
|
||||
@ -138,7 +138,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton
|
||||
}}
|
||||
/>
|
||||
{deleteModal}
|
||||
{isGrafanaRulerRule(rule.rulerRule) && showSilenceDrawer && (
|
||||
{isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && (
|
||||
<AlertmanagerProvider accessType="instance">
|
||||
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
|
||||
</AlertmanagerProvider>
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2, dateTime, dateTimeFormat } from '@grafana/data';
|
||||
import { useStyles2, Tooltip } from '@grafana/ui';
|
||||
import { Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Time } from 'app/features/explore/Time';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { useCleanAnnotations } from '../../utils/annotations';
|
||||
import { isRecordingRulerRule } from '../../utils/rules';
|
||||
import { isGrafanaRecordingRule, isRecordingRulerRule } from '../../utils/rules';
|
||||
import { isNullDate } from '../../utils/time';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { DetailsField } from '../DetailsField';
|
||||
@ -68,6 +68,7 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
|
||||
let every = rule.group.interval;
|
||||
let lastEvaluation = rule.promRule?.lastEvaluation;
|
||||
let lastEvaluationDuration = rule.promRule?.evaluationTime;
|
||||
const metric = isGrafanaRecordingRule(rule.rulerRule) ? rule.rulerRule?.grafana_alert.record?.metric : undefined;
|
||||
|
||||
// recording rules don't have a for duration
|
||||
if (!isRecordingRulerRule(rule.rulerRule)) {
|
||||
@ -76,6 +77,11 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
{metric && (
|
||||
<DetailsField label="Metric" horizontal={true}>
|
||||
{metric}
|
||||
</DetailsField>
|
||||
)}
|
||||
{every && (
|
||||
<DetailsField label="Evaluate" horizontal={true}>
|
||||
Every {every}
|
||||
|
@ -10,7 +10,7 @@ import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { isCloudRulesSource } from '../../utils/datasource';
|
||||
import { createExploreLink } from '../../utils/misc';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { isFederatedRuleGroup, isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
@ -103,7 +103,7 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGrafanaRulerRule(rule.rulerRule)) {
|
||||
if (isGrafanaAlertingRule(rule.rulerRule)) {
|
||||
buttons.push(
|
||||
<Fragment key="history">
|
||||
<Button
|
||||
|
@ -2,11 +2,13 @@ import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||
import { Spinner, useStyles2, Stack } from '@grafana/ui';
|
||||
import { Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { isAlertingRule, isRecordingRule, getFirstActiveAt } from '../../utils/rules';
|
||||
import { getFirstActiveAt, isAlertingRule, isGrafanaRecordingRule, isRecordingRule } from '../../utils/rules';
|
||||
import { StateTag } from '../StateTag';
|
||||
|
||||
import { AlertStateTag } from './AlertStateTag';
|
||||
|
||||
@ -19,9 +21,22 @@ interface Props {
|
||||
|
||||
export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) => {
|
||||
const style = useStyles2(getStyle);
|
||||
const { promRule } = rule;
|
||||
|
||||
const { promRule, rulerRule } = rule;
|
||||
// return how long the rule has been in its firing state, if any
|
||||
const RecordingRuleState = () => {
|
||||
if (isPaused && isGrafanaRecordingRule(rulerRule)) {
|
||||
return (
|
||||
<Tooltip content={'Recording rule evaluation is currently paused'} placement="top">
|
||||
<StateTag state="warning">
|
||||
<Icon name="pause" size="xs" />
|
||||
<Trans i18nKey="alerting.rule-state.paused">Paused</Trans>
|
||||
</StateTag>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return <Trans i18nKey="alerting.rule-state.recording-rule">Recording rule</Trans>;
|
||||
}
|
||||
};
|
||||
const forTime = useMemo(() => {
|
||||
if (
|
||||
promRule &&
|
||||
@ -55,14 +70,14 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Spinner />
|
||||
Deleting
|
||||
<Trans i18nKey="alerting.rule-state.deleting">Deleting</Trans>
|
||||
</Stack>
|
||||
);
|
||||
} else if (isCreating) {
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Spinner />
|
||||
Creating
|
||||
<Trans i18nKey="alerting.rule-state.creating">Creating</Trans>
|
||||
</Stack>
|
||||
);
|
||||
} else if (promRule && isAlertingRule(promRule)) {
|
||||
@ -73,7 +88,7 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) =>
|
||||
</Stack>
|
||||
);
|
||||
} else if (promRule && isRecordingRule(promRule)) {
|
||||
return <>Recording rule</>;
|
||||
return <RecordingRuleState />;
|
||||
}
|
||||
return <>n/a</>;
|
||||
};
|
||||
|
@ -11,7 +11,7 @@ import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { isAdmin } from '../utils/misc';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
|
||||
import { isFederatedRuleGroup, isGrafanaRecordingRule, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
|
||||
|
||||
import { useIsRuleEditable } from './useIsRuleEditable';
|
||||
|
||||
@ -284,6 +284,7 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
|
||||
function useCanSilence(rule: CombinedRule): [boolean, boolean] {
|
||||
const rulesSource = rule.namespace.rulesSource;
|
||||
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
|
||||
const isGrafanaRecording = isGrafanaRecordingRule(rule.rulerRule);
|
||||
|
||||
const { currentData: amConfigStatus, isLoading } =
|
||||
alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, {
|
||||
@ -293,9 +294,9 @@ function useCanSilence(rule: CombinedRule): [boolean, boolean] {
|
||||
const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
|
||||
const { loading: folderIsLoading, folder } = useFolder(folderUID);
|
||||
|
||||
// we don't support silencing when the rule is not a Grafana managed rule
|
||||
// we don't support silencing when the rule is not a Grafana managed alerting rule
|
||||
// we simply don't know what Alertmanager the ruler is sending alerts to
|
||||
if (!isGrafanaManagedRule || isLoading || folderIsLoading || !folder) {
|
||||
if (!isGrafanaManagedRule || isGrafanaRecording || isLoading || folderIsLoading || !folder) {
|
||||
return [false, false];
|
||||
}
|
||||
|
||||
|
@ -142,6 +142,42 @@ export const mockRulerGrafanaRule = (
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
export const mockRulerGrafanaRecordingRule = (
|
||||
partial: Partial<RulerGrafanaRuleDTO> = {},
|
||||
partialDef: Partial<GrafanaRuleDefinition> = {}
|
||||
): RulerGrafanaRuleDTO => {
|
||||
return {
|
||||
grafana_alert: {
|
||||
uid: '123',
|
||||
title: 'myalert',
|
||||
namespace_uid: '123',
|
||||
rule_group: 'my-group',
|
||||
condition: 'A',
|
||||
record: {
|
||||
metric: 'myalert',
|
||||
from: 'A',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
datasourceUid: '123',
|
||||
refId: 'A',
|
||||
queryType: 'huh',
|
||||
model: {
|
||||
refId: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
...partialDef,
|
||||
},
|
||||
annotations: {
|
||||
message: 'alert with severity "{{.warning}}}"',
|
||||
},
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
},
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {}): RulerAlertingRuleDTO => ({
|
||||
alert: 'alert1',
|
||||
|
@ -52,7 +52,7 @@ import { discoverFeatures } from '../api/buildInfo';
|
||||
import { fetchNotifiers } from '../api/grafana';
|
||||
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
|
||||
import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
|
||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
|
||||
import {
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
@ -64,7 +64,13 @@ import { makeAMLink } from '../utils/misc';
|
||||
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
import { getRulerClient } from '../utils/rulerClient';
|
||||
import { getAlertInfo, isGrafanaRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
|
||||
import {
|
||||
getAlertInfo,
|
||||
isDataSourceManagedRuleByType,
|
||||
isGrafanaManagedRuleByType,
|
||||
isGrafanaRulerRule,
|
||||
isRulerNotSupportedResponse,
|
||||
} from '../utils/rules';
|
||||
import { safeParsePrometheusDuration } from '../utils/time';
|
||||
|
||||
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
|
||||
@ -357,13 +363,16 @@ export const saveRuleFormAction = createAsyncThunk(
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const { type } = values;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO getRulerConfig should be smart enough to provide proper rulerClient implementation
|
||||
// For the dataSourceName specified
|
||||
// in case of system (cortex/loki)
|
||||
let identifier: RuleIdentifier;
|
||||
|
||||
if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) {
|
||||
if (isDataSourceManagedRuleByType(type)) {
|
||||
if (!values.dataSourceName) {
|
||||
throw new Error('The Data source has not been defined.');
|
||||
}
|
||||
@ -372,8 +381,8 @@ export const saveRuleFormAction = createAsyncThunk(
|
||||
const rulerClient = getRulerClient(rulerConfig);
|
||||
identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
|
||||
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
|
||||
// in case of grafana managed
|
||||
} else if (type === RuleFormType.grafana) {
|
||||
// in case of grafana managed rules or grafana-managed recording rules
|
||||
} else if (isGrafanaManagedRuleByType(type)) {
|
||||
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
|
||||
const rulerClient = getRulerClient(rulerConfig);
|
||||
identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
|
||||
@ -656,10 +665,10 @@ export const testReceiversAction = createAsyncThunk(
|
||||
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
|
||||
return rules.filter((rule: RulerRuleDTO) => {
|
||||
const { forDuration } = getAlertInfo(rule, everyDuration);
|
||||
const forNumber = safeParsePrometheusDuration(forDuration);
|
||||
const forNumber = forDuration ? safeParsePrometheusDuration(forDuration) : null;
|
||||
const everyNumber = safeParsePrometheusDuration(everyDuration);
|
||||
|
||||
return forNumber !== 0 && forNumber < everyNumber;
|
||||
return forNumber ? forNumber !== 0 && forNumber < everyNumber : false;
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,8 @@ import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alertin
|
||||
import { Folder } from '../components/rule-editor/RuleFolderPicker';
|
||||
|
||||
export enum RuleFormType {
|
||||
grafana = 'grafana',
|
||||
grafana = 'grafana-alerting',
|
||||
grafanaRecording = 'grafana-recording',
|
||||
cloudAlerting = 'cloud-alerting',
|
||||
cloudRecording = 'cloud-recording',
|
||||
}
|
||||
@ -45,6 +46,7 @@ export interface RuleFormValues {
|
||||
isPaused?: boolean;
|
||||
manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule
|
||||
contactPoints?: AlertManagerManualRouting;
|
||||
metric?: string;
|
||||
|
||||
// cortex / loki rules
|
||||
namespace: string;
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values 1`] = `
|
||||
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana alerting rule 1`] = `
|
||||
{
|
||||
"annotations": {
|
||||
"description": "",
|
||||
@ -23,6 +23,29 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana recording rule 1`] = `
|
||||
{
|
||||
"annotations": {
|
||||
"description": "",
|
||||
"runbook_url": "",
|
||||
"summary": "",
|
||||
},
|
||||
"grafana_alert": {
|
||||
"condition": "A",
|
||||
"data": [],
|
||||
"is_paused": false,
|
||||
"record": {
|
||||
"from": "A",
|
||||
"metric": "",
|
||||
},
|
||||
"title": "",
|
||||
},
|
||||
"labels": {
|
||||
"": "",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = `
|
||||
{
|
||||
"annotations": {
|
||||
|
@ -20,7 +20,7 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null
|
||||
|
||||
if (isGrafanaRulerRule(rulerRule)) {
|
||||
const query = rulerRule.grafana_alert.data;
|
||||
return widenRelativeTimeRanges(query, rulerRule.for, combinedRule.group.interval);
|
||||
return widenRelativeTimeRanges(query, rulerRule.for ?? '', combinedRule.group.interval);
|
||||
}
|
||||
|
||||
if (isCloudRulesSource(rulesSource)) {
|
||||
|
@ -17,10 +17,21 @@ import {
|
||||
} from './rule-form';
|
||||
|
||||
describe('formValuesToRulerGrafanaRuleDTO', () => {
|
||||
it('should correctly convert rule form values', () => {
|
||||
it('should correctly convert rule form values for grafana alerting rule', () => {
|
||||
const formValues: RuleFormValues = {
|
||||
...getDefaultFormValues(),
|
||||
condition: 'A',
|
||||
type: RuleFormType.grafana,
|
||||
};
|
||||
|
||||
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should correctly convert rule form values for grafana recording rule', () => {
|
||||
const formValues: RuleFormValues = {
|
||||
...getDefaultFormValues(),
|
||||
condition: 'A',
|
||||
type: RuleFormType.grafanaRecording,
|
||||
};
|
||||
|
||||
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
|
||||
@ -31,6 +42,7 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
|
||||
|
||||
const values: RuleFormValues = {
|
||||
...defaultValues,
|
||||
type: RuleFormType.grafana,
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
|
@ -47,7 +47,13 @@ import { getRulesAccess } from './access-control';
|
||||
import { Annotation, defaultAnnotations } from './constants';
|
||||
import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
|
||||
import { arrayToRecord, recordToArray } from './misc';
|
||||
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
|
||||
import {
|
||||
isAlertingRulerRule,
|
||||
isGrafanaAlertingRuleByType,
|
||||
isGrafanaRecordingRule,
|
||||
isGrafanaRulerRule,
|
||||
isRecordingRulerRule,
|
||||
} from './rules';
|
||||
import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time';
|
||||
|
||||
export type PromOrLokiQuery = PromQuery | LokiQuery;
|
||||
@ -194,28 +200,61 @@ export function getNotificationSettingsForDTO(
|
||||
}
|
||||
|
||||
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
|
||||
const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused, contactPoints, manualRouting } =
|
||||
values;
|
||||
const {
|
||||
name,
|
||||
condition,
|
||||
noDataState,
|
||||
execErrState,
|
||||
evaluateFor,
|
||||
queries,
|
||||
isPaused,
|
||||
contactPoints,
|
||||
manualRouting,
|
||||
type,
|
||||
metric,
|
||||
} = values;
|
||||
if (condition) {
|
||||
const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO(
|
||||
manualRouting,
|
||||
contactPoints
|
||||
);
|
||||
if (isGrafanaAlertingRuleByType(type)) {
|
||||
return {
|
||||
grafana_alert: {
|
||||
title: name,
|
||||
condition,
|
||||
data: queries.map(fixBothInstantAndRangeQuery),
|
||||
is_paused: Boolean(isPaused),
|
||||
|
||||
return {
|
||||
grafana_alert: {
|
||||
title: name,
|
||||
condition,
|
||||
no_data_state: noDataState,
|
||||
exec_err_state: execErrState,
|
||||
data: queries.map(fixBothInstantAndRangeQuery),
|
||||
is_paused: Boolean(isPaused),
|
||||
notification_settings: notificationSettings,
|
||||
},
|
||||
for: evaluateFor,
|
||||
annotations: arrayToRecord(values.annotations || []),
|
||||
labels: arrayToRecord(values.labels || []),
|
||||
};
|
||||
// Alerting rule specific
|
||||
no_data_state: noDataState,
|
||||
exec_err_state: execErrState,
|
||||
notification_settings: notificationSettings,
|
||||
},
|
||||
annotations: arrayToRecord(values.annotations || []),
|
||||
labels: arrayToRecord(values.labels || []),
|
||||
|
||||
// Alerting rule specific
|
||||
for: evaluateFor,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
grafana_alert: {
|
||||
title: name,
|
||||
condition,
|
||||
data: queries.map(fixBothInstantAndRangeQuery),
|
||||
is_paused: Boolean(isPaused),
|
||||
|
||||
// Recording rule specific
|
||||
record: {
|
||||
metric: metric ?? name,
|
||||
from: condition,
|
||||
},
|
||||
},
|
||||
annotations: arrayToRecord(values.annotations || []),
|
||||
labels: arrayToRecord(values.labels || []),
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error('Cannot create rule without specifying alert condition');
|
||||
}
|
||||
@ -251,34 +290,56 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
||||
|
||||
const defaultFormValues = getDefaultFormValues();
|
||||
if (isGrafanaRulesSource(ruleSourceName)) {
|
||||
if (isGrafanaRulerRule(rule)) {
|
||||
// GRAFANA-MANAGED RULES
|
||||
if (isGrafanaRecordingRule(rule)) {
|
||||
// grafana recording rule
|
||||
const ga = rule.grafana_alert;
|
||||
|
||||
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
|
||||
|
||||
return {
|
||||
...defaultFormValues,
|
||||
name: ga.title,
|
||||
type: RuleFormType.grafana,
|
||||
type: RuleFormType.grafanaRecording,
|
||||
group: group.name,
|
||||
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
||||
evaluateFor: rule.for || '0',
|
||||
noDataState: ga.no_data_state,
|
||||
execErrState: ga.exec_err_state,
|
||||
queries: ga.data,
|
||||
condition: ga.condition,
|
||||
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
|
||||
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||
folder: { title: namespace, uid: ga.namespace_uid },
|
||||
isPaused: ga.is_paused,
|
||||
|
||||
contactPoints: routingSettings,
|
||||
manualRouting: Boolean(routingSettings),
|
||||
metric: ga.record?.metric,
|
||||
};
|
||||
} else if (isGrafanaRulerRule(rule)) {
|
||||
// grafana alerting rule
|
||||
const ga = rule.grafana_alert;
|
||||
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
|
||||
if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) {
|
||||
return {
|
||||
...defaultFormValues,
|
||||
name: ga.title,
|
||||
type: RuleFormType.grafana,
|
||||
group: group.name,
|
||||
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
||||
evaluateFor: rule.for || '0',
|
||||
noDataState: ga.no_data_state,
|
||||
execErrState: ga.exec_err_state,
|
||||
queries: ga.data,
|
||||
condition: ga.condition,
|
||||
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
|
||||
labels: listifyLabelsOrAnnotations(rule.labels, true),
|
||||
folder: { title: namespace, uid: ga.namespace_uid },
|
||||
isPaused: ga.is_paused,
|
||||
|
||||
contactPoints: routingSettings,
|
||||
manualRouting: Boolean(routingSettings),
|
||||
};
|
||||
} else {
|
||||
throw new Error('Unexpected type of rule for grafana rules source');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unexpected type of rule for grafana rules source');
|
||||
}
|
||||
} else {
|
||||
// DATASOURCE-MANAGED RULES
|
||||
if (isAlertingRulerRule(rule)) {
|
||||
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
|
||||
|
||||
|
@ -10,19 +10,18 @@ import {
|
||||
CombinedRuleGroup,
|
||||
CombinedRuleWithLocation,
|
||||
GrafanaRuleIdentifier,
|
||||
PrometheusRuleIdentifier,
|
||||
PromRuleWithLocation,
|
||||
PrometheusRuleIdentifier,
|
||||
RecordingRule,
|
||||
Rule,
|
||||
RuleIdentifier,
|
||||
RuleGroupIdentifier,
|
||||
RuleIdentifier,
|
||||
RuleNamespace,
|
||||
RuleWithLocation,
|
||||
} from 'app/types/unified-alerting';
|
||||
import {
|
||||
GrafanaAlertState,
|
||||
GrafanaAlertStateWithReason,
|
||||
mapStateWithReasonToBaseState,
|
||||
PostableRuleDTO,
|
||||
PromAlertingRuleState,
|
||||
PromRuleType,
|
||||
@ -31,11 +30,13 @@ import {
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRecordingRuleDTO,
|
||||
RulerRuleDTO,
|
||||
mapStateWithReasonToBaseState,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
|
||||
import { State } from '../components/StateTag';
|
||||
import { RuleHealth } from '../search/rulesSearchParser';
|
||||
import { RuleFormType } from '../types/rule-form';
|
||||
|
||||
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
||||
import { getRulesSourceName, isGrafanaRulesSource } from './datasource';
|
||||
@ -59,10 +60,29 @@ export function isRecordingRulerRule(rule?: RulerRuleDTO): rule is RulerRecordin
|
||||
return typeof rule === 'object' && 'record' in rule;
|
||||
}
|
||||
|
||||
export function isGrafanaOrDataSourceRecordingRule(rule?: RulerRuleDTO) {
|
||||
return (
|
||||
(typeof rule === 'object' && isRecordingRulerRule(rule)) ||
|
||||
(isGrafanaRulerRule(rule) && 'record' in rule.grafana_alert)
|
||||
);
|
||||
}
|
||||
|
||||
export function isGrafanaRecordingRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
|
||||
return typeof rule === 'object' && isGrafanaOrDataSourceRecordingRule(rule) && isGrafanaRulerRule(rule);
|
||||
}
|
||||
|
||||
export function isGrafanaAlertingRule(rule?: RulerRuleDTO): rule is RulerGrafanaRuleDTO {
|
||||
return typeof rule === 'object' && isGrafanaRulerRule(rule) && !isGrafanaOrDataSourceRecordingRule(rule);
|
||||
}
|
||||
|
||||
export function isGrafanaRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerGrafanaRuleDTO {
|
||||
return typeof rule === 'object' && 'grafana_alert' in rule;
|
||||
}
|
||||
|
||||
export function isGrafanaRecordingRulerRule(rule?: RulerRuleDTO) {
|
||||
return typeof rule === 'object' && 'grafana_alert' in rule && 'record' in rule.grafana_alert;
|
||||
}
|
||||
|
||||
export function isCloudRulerRule(rule?: RulerRuleDTO | PostableRuleDTO): rule is RulerCloudRuleDTO {
|
||||
return typeof rule === 'object' && !isGrafanaRulerRule(rule);
|
||||
}
|
||||
@ -254,8 +274,8 @@ export function getRuleName(rule: RulerRuleDTO) {
|
||||
|
||||
export interface AlertInfo {
|
||||
alertName: string;
|
||||
forDuration: string;
|
||||
evaluationsToFire: number;
|
||||
forDuration?: string;
|
||||
evaluationsToFire: number | null;
|
||||
}
|
||||
|
||||
export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => {
|
||||
@ -268,7 +288,7 @@ export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): Al
|
||||
return {
|
||||
alertName: alert.grafana_alert.title,
|
||||
forDuration: alert.for,
|
||||
evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation),
|
||||
evaluationsToFire: alert.for ? getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation) : null,
|
||||
};
|
||||
}
|
||||
if (isAlertingRulerRule(alert)) {
|
||||
@ -329,3 +349,31 @@ export function getRuleGroupLocationFromRuleWithLocation(rule: RuleWithLocation)
|
||||
groupName,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGrafanaAlertingRuleByType(type?: RuleFormType) {
|
||||
return type === RuleFormType.grafana;
|
||||
}
|
||||
|
||||
export function isGrafanaRecordingRuleByType(type: RuleFormType) {
|
||||
return type === RuleFormType.grafanaRecording;
|
||||
}
|
||||
|
||||
export function isCloudAlertingRuleByType(type?: RuleFormType) {
|
||||
return type === RuleFormType.cloudAlerting;
|
||||
}
|
||||
|
||||
export function isCloudRecordingRuleByType(type?: RuleFormType) {
|
||||
return type === RuleFormType.cloudRecording;
|
||||
}
|
||||
|
||||
export function isGrafanaManagedRuleByType(type: RuleFormType) {
|
||||
return isGrafanaAlertingRuleByType(type) || isGrafanaRecordingRuleByType(type);
|
||||
}
|
||||
|
||||
export function isRecordingRuleByType(type: RuleFormType) {
|
||||
return isGrafanaRecordingRuleByType(type) || isCloudRecordingRuleByType(type);
|
||||
}
|
||||
|
||||
export function isDataSourceManagedRuleByType(type: RuleFormType) {
|
||||
return isCloudAlertingRuleByType(type) || isCloudRecordingRuleByType(type);
|
||||
}
|
||||
|
@ -108,6 +108,6 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
"type": "grafana",
|
||||
"type": "grafana-alerting",
|
||||
}
|
||||
`;
|
||||
|
@ -222,11 +222,15 @@ export interface PostableGrafanaRuleDefinition {
|
||||
uid?: string;
|
||||
title: string;
|
||||
condition: string;
|
||||
no_data_state: GrafanaAlertStateDecision;
|
||||
exec_err_state: GrafanaAlertStateDecision;
|
||||
no_data_state?: GrafanaAlertStateDecision;
|
||||
exec_err_state?: GrafanaAlertStateDecision;
|
||||
data: AlertQuery[];
|
||||
is_paused?: boolean;
|
||||
notification_settings?: GrafanaNotificationSettings;
|
||||
record?: {
|
||||
metric: string;
|
||||
from: string;
|
||||
};
|
||||
}
|
||||
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||
id?: string;
|
||||
@ -238,7 +242,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||
|
||||
export interface RulerGrafanaRuleDTO<T = GrafanaRuleDefinition> {
|
||||
grafana_alert: T;
|
||||
for: string;
|
||||
for?: string;
|
||||
annotations: Annotations;
|
||||
labels: Labels;
|
||||
}
|
||||
|
@ -112,6 +112,12 @@
|
||||
"used-by_one": "Used by {{ count }} notification policy",
|
||||
"used-by_other": "Used by {{ count }} notification policies"
|
||||
},
|
||||
"grafana-rules": {
|
||||
"export-rules": "Export rules",
|
||||
"loading": "Loading...",
|
||||
"new-recording-rule": "New recording rule",
|
||||
"title": "Grafana"
|
||||
},
|
||||
"policies": {
|
||||
"metadata": {
|
||||
"timingOptions": {
|
||||
@ -144,6 +150,12 @@
|
||||
"success": "Successfully updated rule group"
|
||||
}
|
||||
},
|
||||
"rule-state": {
|
||||
"creating": "Creating",
|
||||
"deleting": "Deleting",
|
||||
"paused": "Paused",
|
||||
"recording-rule": "Recording rule"
|
||||
},
|
||||
"rules": {
|
||||
"delete-rule": {
|
||||
"success": "Rule successfully deleted"
|
||||
|
@ -112,6 +112,12 @@
|
||||
"used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy",
|
||||
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş"
|
||||
},
|
||||
"grafana-rules": {
|
||||
"export-rules": "Ēχpőřŧ řūľęş",
|
||||
"loading": "Ŀőäđįʼnģ...",
|
||||
"new-recording-rule": "Ńęŵ řęčőřđįʼnģ řūľę",
|
||||
"title": "Ğřäƒäʼnä"
|
||||
},
|
||||
"policies": {
|
||||
"metadata": {
|
||||
"timingOptions": {
|
||||
@ -144,6 +150,12 @@
|
||||
"success": "Ŝūččęşşƒūľľy ūpđäŧęđ řūľę ģřőūp"
|
||||
}
|
||||
},
|
||||
"rule-state": {
|
||||
"creating": "Cřęäŧįʼnģ",
|
||||
"deleting": "Đęľęŧįʼnģ",
|
||||
"paused": "Päūşęđ",
|
||||
"recording-rule": "Ŗęčőřđįʼnģ řūľę"
|
||||
},
|
||||
"rules": {
|
||||
"delete-rule": {
|
||||
"success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ"
|
||||
|
Loading…
Reference in New Issue
Block a user