Alerting: Simplified routing part3 (#79941)

* Update alert rule model in FE following BE design doc

* Remove unnecessary conditional rendering

* Update styles for optional route settings: add indentation

* Update test

* Add validation for  grouBy to include grafana_folder and alertname

* Split conversions between FEdataModel/ DTO, in separate functions

* Update texts following Brenda's suggestions

* Update text
This commit is contained in:
Sonia Aguilar 2024-01-09 08:39:43 +01:00 committed by GitHub
parent 48612063dd
commit 6f8ddac4eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 262 additions and 38 deletions

View File

@ -80,9 +80,9 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
* - simplified routing is enabled
* - the alert rule is a grafana rule
*
* This component will render the switch between the manual routing and the notification policy routing.
* This component will render the switch between the select contact point routing and the notification policy routing.
* It also renders the section body of the NotificationsStep, depending on the routing option selected.
* If manual routing is selected, it will render the SimplifiedRouting component.
* If select contact point routing is selected, it will render the SimplifiedRouting component.
* If notification policy routing is selected, it will render the AutomaticRouting component.
*
*/
@ -93,8 +93,8 @@ function ManualAndAutomaticRouting({ alertUid }: { alertUid?: string }) {
const [manualRouting] = watch(['manualRouting']);
const routingOptions = [
{ label: 'Manually select contact point', value: RoutingOptions.ContactPoint },
{ label: 'Auto-select contact point', value: RoutingOptions.NotificationPolicy },
{ label: 'Select contact point', value: RoutingOptions.ContactPoint },
{ label: 'Use notification policy', value: RoutingOptions.NotificationPolicy },
];
const onRoutingOptionChange = (option: RoutingOptions) => {
@ -229,7 +229,7 @@ export const RoutingOptionDescription = ({ manualRouting }: NotificationsStepDes
<Text variant="bodySmall" color="secondary">
{manualRouting
? 'Notifications for firing alerts are routed to a selected contact point.'
: 'Notifications for firing alerts are routed to contact points based on matching labels.'}
: 'Notifications for firing alerts are routed to contact points based on matching labels and the notification policy tree.'}
</Text>
{manualRouting ? <NeedHelpInfoForContactpoint /> : <NeedHelpInfoForNotificationPolicy />}
</div>

View File

@ -23,7 +23,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
const alertManagerName = alertManager.name;
const { isLoading, error: errorInContactPointStatus, contactPoints } = useContactPointsWithStatus();
const shouldShowAM = true;
const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState<
ContactPointWithMetadata | undefined
>();
@ -36,17 +35,15 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
}
return (
<Stack direction="column">
{shouldShowAM && (
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine}></div>
<div className={styles.alertManagerName}>
Alert manager:
<img src={alertManager.imgUrl} alt="Alert manager logo" className={styles.img} />
{alertManagerName}
</div>
<div className={styles.secondAlertManagerLine}></div>
</Stack>
)}
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine}></div>
<div className={styles.alertManagerName}>
Alert manager:
<img src={alertManager.imgUrl} alt="Alert manager logo" className={styles.img} />
{alertManagerName}
</div>
<div className={styles.secondAlertManagerLine}></div>
</Stack>
<Stack direction="row" gap={1} alignItems="center">
<ContactPointSelector
alertManager={alertManagerName}
@ -59,7 +56,11 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
<ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} />
)}
<div className={styles.routingSection}>
<CollapsableSection label="Muting, grouping and timings" isOpen={false} className={styles.collapsableSection}>
<CollapsableSection
label="Muting, grouping and timings (optional)"
isOpen={false}
className={styles.collapsableSection}
>
<Stack direction="column" gap={1}>
<MuteTimingFields alertManager={alertManagerName} />
<RoutingSettings alertManager={alertManagerName} />
@ -74,7 +75,7 @@ function LinkToContactPoints() {
return (
<Link target="_blank" href={createUrl(hrefToContactPoints)} rel="noopener" aria-label="View alert rule">
<Stack direction="row" gap={1} alignItems="center" justifyContent="center">
<Text color="secondary">To browse contact points and create new ones go to</Text>
<Text color="secondary">To browse contact points and create new ones, go to</Text>
<Text color="link">Contact points</Text>
<Icon name={'external-link-alt'} size="sm" color="link" />
</Stack>

View File

@ -24,7 +24,7 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) {
<Field
label="Mute timings"
data-testid="am-mute-timing-select"
description="Add mute timing to policy"
description="Select a mute timing to define when not to send notifications for this alert rule"
invalid={!!errors.contactPoints?.[alertManager]?.muteTimeIntervals}
>
<InputControl
@ -35,6 +35,7 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) {
className={styles.input}
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
options={muteTimingOptions}
placeholder="Select mute timings..."
/>
)}
control={control}

View File

@ -1,7 +1,19 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, FieldValidationMessage, InputControl, MultiSelect, Stack, Switch, Text, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import {
Field,
FieldValidationMessage,
InlineField,
InputControl,
MultiSelect,
Stack,
Switch,
Text,
useStyles2,
} from '@grafana/ui';
import { useAlertmanagerConfig } from 'app/features/alerting/unified/hooks/useAlertmanagerConfig';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
@ -32,12 +44,14 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
const { groupBy, groupIntervalValue, groupWaitValue, repeatIntervalValue } = useGetDefaultsForRoutingSettings();
const overrideGrouping = watch(`contactPoints.${alertManager}.overrideGrouping`);
const overrideTimings = watch(`contactPoints.${alertManager}.overrideTimings`);
const requiredFieldsInGroupBy = ['grafana_folder', 'alertname'];
const styles = useStyles2(getStyles);
return (
<Stack direction="column">
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<Field label="Override grouping">
<InlineField label="Override grouping" transparent={true} className={styles.switchElement}>
<Switch id="override-grouping-toggle" {...register(`contactPoints.${alertManager}.overrideGrouping`)} />
</Field>
</InlineField>
{!overrideGrouping && (
<Text variant="body" color="secondary">
Grouping: <strong>{groupBy.join(', ')}</strong>
@ -50,8 +64,22 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
description="Group alerts when you receive a notification based on labels. If empty it will be inherited from the default notification policy."
{...register(`contactPoints.${alertManager}.groupBy`, { required: true })}
invalid={!!errors.contactPoints?.[alertManager]?.groupBy}
className={styles.optionalContent}
>
<InputControl
rules={{
validate: (value: string[]) => {
if (!value || value.length === 0) {
return 'At least one group by option is required.';
}
// we need to make sure that the required fields are included
const requiredFieldsIncluded = requiredFieldsInGroupBy.every((field) => value.includes(field));
if (!requiredFieldsIncluded) {
return `Group by must include ${requiredFieldsInGroupBy.join(', ')}`;
}
return true;
},
}}
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<MultiSelect
@ -68,7 +96,7 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
options={[...commonGroupByOptions, ...groupByOptions]}
/>
{error && <FieldValidationMessage>{'At least one group by option is required'}</FieldValidationMessage>}
{error && <FieldValidationMessage>{error.message}</FieldValidationMessage>}
</>
)}
name={`contactPoints.${alertManager}.groupBy`}
@ -77,9 +105,9 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
</Field>
)}
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<Field label="Override timings">
<InlineField label="Override timings" transparent={true} className={styles.switchElement}>
<Switch id="override-timings-toggle" {...register(`contactPoints.${alertManager}.overrideTimings`)} />
</Field>
</InlineField>
{!overrideTimings && (
<Text variant="body" color="secondary">
Group wait: <strong>{groupWaitValue}, </strong>
@ -88,7 +116,11 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
</Text>
)}
</Stack>
{overrideTimings && <RouteTimings alertManager={alertManager} />}
{overrideTimings && (
<div className={styles.optionalContent}>
<RouteTimings alertManager={alertManager} />
</div>
)}
</Stack>
);
};
@ -106,3 +138,14 @@ function useGetDefaultsForRoutingSettings() {
};
}, [config]);
}
const getStyles = (theme: GrafanaTheme2) => ({
switchElement: css({
flexFlow: 'row-reverse',
gap: theme.spacing(1),
alignItems: 'center',
}),
optionalContent: css({
marginLeft: '49px',
}),
});

View File

@ -10,11 +10,11 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"for": "5m",
"grafana_alert": {
"condition": "A",
"contactPoints": undefined,
"data": [],
"exec_err_state": "Error",
"is_paused": false,
"no_data_state": "NoData",
"notification_settings": undefined,
"title": "",
},
"labels": {
@ -33,7 +33,6 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"for": "5m",
"grafana_alert": {
"condition": "A",
"contactPoints": undefined,
"data": [
{
"datasourceUid": "dsuid",
@ -54,6 +53,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"exec_err_state": "Error",
"is_paused": false,
"no_data_state": "NoData",
"notification_settings": undefined,
"title": "",
},
"labels": {

View File

@ -1,13 +1,16 @@
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto';
import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import {
alertingRulerRuleToRuleForm,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getContactPointsFromDTO,
getDefaultFormValues,
getNotificationSettingsForDTO,
} from './rule-form';
describe('formValuesToRulerGrafanaRuleDTO', () => {
@ -85,3 +88,122 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
expect(alertingRulerRuleToRuleForm(rule)).toMatchSnapshot();
});
});
describe('getContactPointsFromDTO', () => {
it('should return undefined if notification_settings is not defined', () => {
const ga: GrafanaRuleDefinition = {
uid: '123',
title: 'myalert',
namespace_uid: '123',
condition: 'A',
no_data_state: GrafanaAlertStateDecision.Alerting,
exec_err_state: GrafanaAlertStateDecision.Alerting,
data: [
{
datasourceUid: '123',
refId: 'A',
queryType: 'huh',
model: { refId: 'A' },
},
],
notification_settings: undefined,
};
const result = getContactPointsFromDTO(ga);
expect(result).toBeUndefined();
});
it('should return routingSettings with correct props if notification_settings is defined', () => {
const ga: GrafanaRuleDefinition = {
uid: '123',
title: 'myalert',
namespace_uid: '123',
condition: 'A',
no_data_state: GrafanaAlertStateDecision.Alerting,
exec_err_state: GrafanaAlertStateDecision.Alerting,
data: [
{
datasourceUid: '123',
refId: 'A',
queryType: 'huh',
model: { refId: 'A' },
},
],
notification_settings: {
receiver: 'receiver',
mute_timings: ['mute_timing'],
group_by: ['group_by'],
group_wait: 'group_wait',
group_interval: 'group_interval',
repeat_interval: 'repeat_interval',
},
};
const result = getContactPointsFromDTO(ga);
expect(result).toEqual({
[GRAFANA_RULES_SOURCE_NAME]: {
selectedContactPoint: 'receiver',
muteTimeIntervals: ['mute_timing'],
overrideGrouping: true,
overrideTimings: true,
groupBy: ['group_by'],
groupWaitValue: 'group_wait',
groupIntervalValue: 'group_interval',
repeatIntervalValue: 'repeat_interval',
},
});
});
});
describe('getNotificationSettingsForDTO', () => {
it('should return undefined if manualRouting is false', () => {
const manualRouting = false;
const contactPoints: AlertManagerManualRouting = {
grafana: {
selectedContactPoint: 'receiver',
muteTimeIntervals: ['mute_timing'],
overrideGrouping: true,
overrideTimings: true,
groupBy: ['group_by'],
groupWaitValue: 'group_wait',
groupIntervalValue: 'group_interval',
repeatIntervalValue: 'repeat_interval',
},
};
const result = getNotificationSettingsForDTO(manualRouting, contactPoints);
expect(result).toBeUndefined();
});
it('should return undefined if selectedContactPoint is not defined', () => {
const manualRouting = true;
const result = getNotificationSettingsForDTO(manualRouting, undefined);
expect(result).toBeUndefined();
});
it('should return notification settings if manualRouting is true and selectedContactPoint is defined', () => {
const manualRouting = true;
const contactPoints: AlertManagerManualRouting = {
grafana: {
selectedContactPoint: 'receiver',
muteTimeIntervals: ['mute_timing'],
overrideGrouping: true,
overrideTimings: true,
groupBy: ['group_by'],
groupWaitValue: 'group_wait',
groupIntervalValue: 'group_interval',
repeatIntervalValue: 'repeat_interval',
},
};
const result = getNotificationSettingsForDTO(manualRouting, contactPoints);
expect(result).toEqual({
receiver: 'receiver',
mute_timings: ['mute_timing'],
group_by: ['group_by'],
group_wait: 'group_wait',
group_interval: 'group_interval',
repeat_interval: 'repeat_interval',
});
});
});

View File

@ -25,6 +25,8 @@ import {
AlertQuery,
Annotations,
GrafanaAlertStateDecision,
GrafanaNotificationSettings,
GrafanaRuleDefinition,
Labels,
PostableRuleGrafanaRuleDTO,
RulerAlertingRuleDTO,
@ -33,11 +35,11 @@ import {
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
import { getRulesAccess } from './access-control';
import { Annotation, defaultAnnotations } from './constants';
import { getDefaultOrFirstCompatibleDataSource, isGrafanaRulesSource } from './datasource';
import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import { parseInterval } from './time';
@ -138,10 +140,34 @@ export function normalizeDefaultAnnotations(annotations: Array<{ key: string; va
return orderedAnnotations;
}
export function getNotificationSettingsForDTO(
manualRouting: boolean,
contactPoints?: AlertManagerManualRouting
): GrafanaNotificationSettings | undefined {
if (contactPoints?.grafana?.selectedContactPoint && manualRouting) {
return {
receiver: contactPoints?.grafana?.selectedContactPoint,
mute_timings: contactPoints?.grafana?.muteTimeIntervals,
group_by: contactPoints?.grafana?.overrideGrouping ? contactPoints?.grafana?.groupBy : undefined,
group_wait: contactPoints?.grafana?.overrideTimings ? contactPoints?.grafana?.groupWaitValue : undefined,
group_interval: contactPoints?.grafana?.overrideTimings ? contactPoints?.grafana?.groupIntervalValue : undefined,
repeat_interval: contactPoints?.grafana?.overrideTimings
? contactPoints?.grafana?.repeatIntervalValue
: undefined,
};
}
return undefined;
}
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused, contactPoints, manualRouting } =
values;
if (condition) {
const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO(
manualRouting,
contactPoints
);
return {
grafana_alert: {
title: name,
@ -150,7 +176,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
exec_err_state: execErrState,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
contactPoints: manualRouting ? contactPoints : undefined,
notification_settings: notificationSettings,
},
for: evaluateFor,
annotations: arrayToRecord(values.annotations || []),
@ -160,6 +186,27 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
throw new Error('Cannot create rule without specifying alert condition');
}
export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManagerManualRouting | undefined {
const contactPoint: ContactPoint | undefined = ga.notification_settings
? {
selectedContactPoint: ga.notification_settings.receiver,
muteTimeIntervals: ga.notification_settings.mute_timings ?? [],
overrideGrouping: Boolean(ga.notification_settings?.group_by),
overrideTimings: Boolean(ga.notification_settings.group_wait),
groupBy: ga.notification_settings.group_by || [],
groupWaitValue: ga.notification_settings.group_wait || '',
groupIntervalValue: ga.notification_settings.group_interval || '',
repeatIntervalValue: ga.notification_settings.repeat_interval || '',
}
: undefined;
const routingSettings: AlertManagerManualRouting | undefined = contactPoint
? {
[GRAFANA_RULES_SOURCE_NAME]: contactPoint,
}
: undefined;
return routingSettings;
}
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
@ -168,6 +215,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
if (isGrafanaRulerRule(rule)) {
const ga = rule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
return {
...defaultFormValues,
name: ga.title,
@ -183,8 +232,9 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
labels: listifyLabelsOrAnnotations(rule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
contactPoints: ga.contactPoints,
manualRouting: Boolean(ga.contactPoints),
contactPoints: routingSettings,
manualRouting: Boolean(routingSettings),
// next line is for testing
// manualRouting: true,
// contactPoints: {

View File

@ -1,7 +1,6 @@
// Prometheus API DTOs, possibly to be autogenerated from openapi spec in the near future
import { DataQuery, RelativeTimeRange } from '@grafana/data';
import { AlertManagerManualRouting } from 'app/features/alerting/unified/types/rule-form';
import { AlertGroupTotals } from './unified-alerting';
@ -198,6 +197,14 @@ export interface AlertQuery {
model: AlertDataQuery;
}
export interface GrafanaNotificationSettings {
receiver: string;
group_by?: string[];
group_wait?: string;
group_interval?: string;
repeat_interval?: string;
mute_timings?: string[];
}
export interface PostableGrafanaRuleDefinition {
uid?: string;
title: string;
@ -206,7 +213,7 @@ export interface PostableGrafanaRuleDefinition {
exec_err_state: GrafanaAlertStateDecision;
data: AlertQuery[];
is_paused?: boolean;
contactPoints?: AlertManagerManualRouting;
notification_settings?: GrafanaNotificationSettings;
}
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
id?: string;