Alerting: Simplified routing part2 (#78671)

* wip

* WIP: Added some actions, timings and grouping

* WIP: remove reducer and use form fields instead

* Show defaults when using override in route settings

* Update alert rule model for simplified routing

* Use defaults in placeholders when overriding timings

* Add validation for contact point

* Add selected contact point details

* Refactor: extract components to separate files and reorg in subfolders

* Fix test

* Update revalidate mode in form

* Extract RuleEditorSectionBody outside NotificationStep component to avoid unmounting any time this one renders

* Remove reValidateMode option in form

* Fix after merging

* Address PR review comments part1

* Address PR review comments part2

* Create routeTimingsFields constant to reuse labels, descriptions and arial labels for the mute timing fields

* Move conditional rendering to the parent in AutomaticRooting and ManualAndAutomaticRouting

* Simplify AlertManagerManualRouting properties
This commit is contained in:
Sonia Aguilar 2024-01-02 08:43:31 +01:00 committed by GitHub
parent ae3156d727
commit f6f259f0b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 709 additions and 333 deletions

View File

@ -9,21 +9,21 @@ import { useToggle } from 'react-use';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import {
Text,
LinkButton,
TabsBar,
TabContent,
Tab,
Pagination,
Button,
Stack,
Alert,
LoadingPlaceholder,
useStyles2,
Menu,
Button,
Dropdown,
Tooltip,
Icon,
LinkButton,
LoadingPlaceholder,
Menu,
Pagination,
Stack,
Tab,
TabContent,
TabsBar,
Text,
Tooltip,
useStyles2,
} from '@grafana/ui';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts';
@ -455,35 +455,56 @@ const ContactPointReceiver = (props: ContactPointReceiverProps) => {
const { name, type, description, diagnostics, pluginMetadata, sendingResolved = true } = props;
const styles = useStyles2(getStyles);
const iconName = INTEGRATION_ICONS[type];
const hasMetadata = diagnostics !== undefined;
return (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0.5}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
{pluginMetadata ? (
<ReceiverMetadataBadge metadata={pluginMetadata} />
) : (
<Text variant="body" color="primary">
{name}
</Text>
)}
</Stack>
{description && (
<Text variant="bodySmall" color="secondary">
{description}
</Text>
)}
</Stack>
<ContactPointReceiverTitleRow
name={name}
type={type}
description={description}
pluginMetadata={pluginMetadata}
/>
{hasMetadata && <ContactPointReceiverMetadataRow diagnostics={diagnostics} sendingResolved={sendingResolved} />}
</Stack>
</div>
);
};
export interface ContactPointReceiverTitleRowProps {
name: string;
type: GrafanaNotifierType | string;
description?: ReactNode;
pluginMetadata?: ReceiverPluginMetadata;
}
export function ContactPointReceiverTitleRow(props: ContactPointReceiverTitleRowProps) {
const { name, type, description, pluginMetadata } = props;
const iconName = INTEGRATION_ICONS[type];
return (
<Stack direction="row" alignItems="center" gap={1}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
{pluginMetadata ? (
<ReceiverMetadataBadge metadata={pluginMetadata} />
) : (
<Text variant="body" color="primary">
{name}
</Text>
)}
</Stack>
{description && (
<Text variant="bodySmall" color="secondary">
{description}
</Text>
)}
</Stack>
);
}
interface ContactPointReceiverMetadata {
sendingResolved: boolean;
diagnostics: NotifierStatus;

View File

@ -3,20 +3,20 @@ import React, { ReactNode, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
Badge,
Button,
Field,
FieldArray,
FieldValidationMessage,
Form,
IconButton,
Input,
InputControl,
MultiSelect,
Select,
Stack,
Switch,
useStyles2,
Badge,
FieldValidationMessage,
Stack,
} from '@grafana/ui';
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
@ -25,20 +25,21 @@ import { FormAmRoute } from '../../types/amroutes';
import { SupportedPlugin } from '../../types/pluginBridges';
import { matcherFieldOptions } from '../../utils/alertmanager';
import {
amRouteToFormAmRoute,
commonGroupByOptions,
emptyArrayFieldMatcher,
mapMultiSelectValueToStrings,
mapSelectValueToString,
stringToSelectableValue,
stringsToSelectableValues,
commonGroupByOptions,
amRouteToFormAmRoute,
promDurationValidator,
repeatIntervalValidator,
stringToSelectableValue,
stringsToSelectableValues,
} from '../../utils/amroutes';
import { AmRouteReceiver } from '../receivers/grafanaAppReceivers/types';
import { PromDurationInput } from './PromDurationInput';
import { getFormStyles } from './formStyles';
import { routeTimingsFields } from './routeTimingsFields';
export interface AmRoutesExpandedFormProps {
receivers: AmRouteReceiver[];
@ -226,32 +227,32 @@ export const AmRoutesExpandedForm = ({
{watch().overrideTimings && (
<>
<Field
label="Group wait"
description="The waiting time until the initial notification is sent for a new group created by an incoming alert. If empty it will be inherited from the parent policy."
label={routeTimingsFields.groupWait.label}
description={routeTimingsFields.groupWait.description}
invalid={!!errors.groupWaitValue}
error={errors.groupWaitValue?.message}
>
<PromDurationInput
{...register('groupWaitValue', { validate: promDurationValidator })}
aria-label="Group wait value"
aria-label={routeTimingsFields.groupWait.ariaLabel}
className={formStyles.promDurationInput}
/>
</Field>
<Field
label="Group interval"
description="The waiting time to send a batch of new alerts for that group after the first notification was sent. If empty it will be inherited from the parent policy."
label={routeTimingsFields.groupInterval.label}
description={routeTimingsFields.groupInterval.description}
invalid={!!errors.groupIntervalValue}
error={errors.groupIntervalValue?.message}
>
<PromDurationInput
{...register('groupIntervalValue', { validate: promDurationValidator })}
aria-label="Group interval value"
aria-label={routeTimingsFields.groupInterval.ariaLabel}
className={formStyles.promDurationInput}
/>
</Field>
<Field
label="Repeat interval"
description="The waiting time to resend an alert after they have successfully been sent."
label={routeTimingsFields.repeatInterval.label}
description={routeTimingsFields.repeatInterval.description}
invalid={!!errors.repeatIntervalValue}
error={errors.repeatIntervalValue?.message}
>
@ -262,7 +263,7 @@ export const AmRoutesExpandedForm = ({
return repeatIntervalValidator(value, groupInterval);
},
})}
aria-label="Repeat interval value"
aria-label={routeTimingsFields.repeatInterval.ariaLabel}
className={formStyles.promDurationInput}
/>
</Field>

View File

@ -0,0 +1,19 @@
export const routeTimingsFields = {
groupWait: {
label: 'Group wait',
description:
'The waiting time until the initial notification is sent for a new group created by an incoming alert. If empty it will be inherited from the parent policy.',
ariaLabel: 'Group wait value',
},
groupInterval: {
label: 'Group interval',
description:
'The waiting time to send a batch of new alerts for that group after the first notification was sent. If empty it will be inherited from the parent policy.',
ariaLabel: 'Group interval value',
},
repeatInterval: {
label: 'Repeat interval',
description: 'The waiting time to resend an alert after they have successfully been sent.',
ariaLabel: 'Repeat interval value',
},
};

View File

@ -25,34 +25,14 @@ enum RoutingOptions {
}
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const { watch, setValue } = useFormContext<RuleFormValues>();
const { watch } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const [type, labels, queries, condition, folder, alertName, manualRouting] = watch([
'type',
'labels',
'queries',
'condition',
'folder',
'name',
'manualRouting',
]);
const [type] = watch(['type', 'labels', 'queries', 'condition', 'folder', 'name', 'manualRouting']);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const shouldRenderPreview = type === RuleFormType.grafana;
const routingOptions = [
{ label: 'Manually select contact point', value: RoutingOptions.ContactPoint },
{ label: 'Auto-select contact point', value: RoutingOptions.NotificationPolicy },
];
const onRoutingOptionChange = (option: RoutingOptions) => {
setValue('manualRouting', option === RoutingOptions.ContactPoint);
};
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
const shouldRenderpreview = type === RuleFormType.grafana;
const shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled;
return (
@ -85,67 +65,86 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
</Text>
</div>
)}
<RuleEditorSectionBody />
{shouldAllowSimplifiedRouting ? ( // when simplified routing is enabled and is grafana rule
<ManualAndAutomaticRouting alertUid={alertUid} />
) : // when simplified routing is not enabled, render the notification preview as we did before
shouldRenderpreview ? (
<AutomaticRooting alertUid={alertUid} />
) : null}
</RuleEditorSection>
);
/**
* This component is used to render the section body of the NotificationsStep, depending on the routing option selected.
* If simplified routing is not enabled, it will render the NotificationPreview component.
* If simplified routing is enabled, it will render the switch between the manual routing and the notification policy routing.
*
*/
function RuleEditorSectionBody() {
if (!shouldAllowSimplifiedRouting) {
return (
<>
{shouldRenderPreview && (
<NotificationPreview
alertQueries={queries}
customLabels={labels}
condition={condition}
folder={folder}
alertName={alertName}
alertUid={alertUid}
/>
)}
</>
);
}
return (
<Stack direction="column">
<Stack direction="column">
<RadioButtonGroup
options={routingOptions}
value={manualRouting ? RoutingOptions.ContactPoint : RoutingOptions.NotificationPolicy}
onChange={onRoutingOptionChange}
className={styles.routingOptions}
/>
</Stack>
<RoutingOptionDescription manualRouting={manualRouting} />
{manualRouting ? (
<div className={styles.simplifiedRouting}>
<SimplifiedRouting />
</div>
) : (
shouldRenderPreview && (
<NotificationPreview
alertQueries={queries}
customLabels={labels}
condition={condition}
folder={folder}
alertName={alertName}
alertUid={alertUid}
/>
)
)}
</Stack>
);
}
};
/**
* Preconditions:
* - 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.
* 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 notification policy routing is selected, it will render the AutomaticRouting component.
*
*/
function ManualAndAutomaticRouting({ alertUid }: { alertUid?: string }) {
const { watch, setValue } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const [manualRouting] = watch(['manualRouting']);
const routingOptions = [
{ label: 'Manually select contact point', value: RoutingOptions.ContactPoint },
{ label: 'Auto-select contact point', value: RoutingOptions.NotificationPolicy },
];
const onRoutingOptionChange = (option: RoutingOptions) => {
setValue('manualRouting', option === RoutingOptions.ContactPoint);
};
return (
<Stack direction="column">
<Stack direction="column">
<RadioButtonGroup
options={routingOptions}
value={manualRouting ? RoutingOptions.ContactPoint : RoutingOptions.NotificationPolicy}
onChange={onRoutingOptionChange}
className={styles.routingOptions}
/>
</Stack>
<RoutingOptionDescription manualRouting={manualRouting} />
{manualRouting ? <SimplifiedRouting /> : <AutomaticRooting alertUid={alertUid} />}
</Stack>
);
}
interface AutomaticRootingProps {
alertUid?: string;
}
function AutomaticRooting({ alertUid }: AutomaticRootingProps) {
const { watch } = useFormContext<RuleFormValues>();
const [labels, queries, condition, folder, alertName] = watch([
'labels',
'queries',
'condition',
'folder',
'name',
'manualRouting',
]);
return (
<NotificationPreview
alertQueries={queries}
customLabels={labels}
condition={condition}
folder={folder}
alertName={alertName}
alertUid={alertUid}
/>
);
}
// Auxiliar components to build the texts and descriptions in the NotificationsStep
function NeedHelpInfoForNotificationPolicy() {
return (
@ -242,11 +241,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
marginTop: theme.spacing(2),
width: 'fit-content',
}),
simplifiedRouting: css({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(2),
}),
configureNotifications: css({
display: 'flex',
flexDirection: 'column',

View File

@ -0,0 +1,119 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, CollapsableSection, Icon, Link, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils';
import { ContactPointDetails } from './contactPoint/ContactPointDetails';
import { ContactPointSelector } from './contactPoint/ContactPointSelector';
import { MuteTimingFields } from './route-settings/MuteTimingFields';
import { RoutingSettings } from './route-settings/RouteSettings';
interface AlertManagerManualRoutingProps {
alertManager: AlertManagerDataSource;
}
export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRoutingProps) {
const styles = useStyles2(getStyles);
const alertManagerName = alertManager.name;
const { isLoading, error: errorInContactPointStatus, contactPoints } = useContactPointsWithStatus();
const shouldShowAM = true;
const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState<
ContactPointWithMetadata | undefined
>();
if (errorInContactPointStatus) {
return <Alert title="Failed to fetch contact points" severity="error" />;
}
if (isLoading) {
return <LoadingPlaceholder text={'Loading...'} />;
}
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" gap={1} alignItems="center">
<ContactPointSelector
alertManager={alertManagerName}
contactPoints={contactPoints}
onSelectContactPoint={setSelectedContactPointWithMetadata}
/>
<LinkToContactPoints />
</Stack>
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
<ContactPointDetails receivers={selectedContactPointWithMetadata.grafana_managed_receiver_configs} />
)}
<div className={styles.routingSection}>
<CollapsableSection label="Muting, grouping and timings" isOpen={false} className={styles.collapsableSection}>
<Stack direction="column" gap={1}>
<MuteTimingFields alertManager={alertManagerName} />
<RoutingSettings alertManager={alertManagerName} />
</Stack>
</CollapsableSection>
</div>
</Stack>
);
}
function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
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="link">Contact points</Text>
<Icon name={'external-link-alt'} size="sm" color="link" />
</Stack>
</Link>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
firstAlertManagerLine: css({
height: 1,
width: theme.spacing(4),
backgroundColor: theme.colors.secondary.main,
}),
alertManagerName: css({
with: 'fit-content',
}),
secondAlertManagerLine: css({
height: '1px',
width: '100%',
flex: 1,
backgroundColor: theme.colors.secondary.main,
}),
img: css({
marginLeft: theme.spacing(2),
width: theme.spacing(3),
height: theme.spacing(3),
marginRight: theme.spacing(1),
}),
collapsableSection: css({
width: 'fit-content',
fontSize: theme.typography.body.fontSize,
}),
routingSection: css({
display: 'flex',
flexDirection: 'column',
maxWidth: theme.breakpoints.values.xl,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
marginTop: theme.spacing(2),
}),
});

View File

@ -1,65 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { AnyAction } from 'redux';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Alert, Field, LoadingPlaceholder, Select, Stack, useStyles2 } from '@grafana/ui';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints';
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
import { selectContactPoint } from './SimplifiedRouting';
export interface ContactPointSelectorProps {
alertManager: AlertManagerDataSource;
selectedReceiver?: string;
dispatch: React.Dispatch<AnyAction>;
}
export function ContactPointSelector({ selectedReceiver, alertManager, dispatch }: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
const onChange = (value: SelectableValue<string>) => {
dispatch(selectContactPoint({ receiver: value?.value, alertManager }));
};
const { isLoading, error, contactPoints: receivers } = useContactPointsWithStatus();
const options = receivers.map((receiver) => {
const integrations = receiver?.grafana_managed_receiver_configs;
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
return { label: receiver.name, value: receiver.name, description };
});
if (error) {
return <Alert title="Failed to fetch contact points" severity="error" />;
}
if (isLoading) {
return <LoadingPlaceholder text={'Loading...'} />;
}
return (
<Stack direction="column">
<Field label="Contact point">
<div className={styles.contactPointsSelector}>
<Select
aria-label="Contact point"
onChange={onChange}
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
// is shared with other components where the "description" _has_ to be a string.
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
// @ts-ignore
options={options}
width={50}
value={selectedReceiver}
/>
</div>
</Field>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
contactPointsSelector: css({
marginTop: theme.spacing(1),
}),
});

View File

@ -1,46 +1,14 @@
import { css } from '@emotion/css';
import { createAction, createReducer } from '@reduxjs/toolkit';
import React, { useEffect, useReducer } from 'react';
import React, { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import {
AlertManagerDataSource,
getAlertManagerDataSourcesByPermission,
} from 'app/features/alerting/unified/utils/datasource';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { getAlertManagerDataSourcesByPermission } from 'app/features/alerting/unified/utils/datasource';
import { ContactPointSelector } from './ContactPointSelector';
export interface AMContactPoint {
alertManager: AlertManagerDataSource;
selectedContactPoint?: string;
}
export const selectContactPoint = createAction<{ receiver: string | undefined; alertManager: AlertManagerDataSource }>(
'simplifiedRouting/selectContactPoint'
);
export const receiversReducer = createReducer<AMContactPoint[]>([], (builder) => {
builder.addCase(selectContactPoint, (state, action) => {
const { receiver, alertManager } = action.payload;
const newContactPoint: AMContactPoint = { selectedContactPoint: receiver, alertManager };
const existingContactPoint = state.find((cp) => cp.alertManager.name === alertManager.name);
if (existingContactPoint) {
existingContactPoint.selectedContactPoint = receiver;
} else {
state.push(newContactPoint);
}
});
});
import { AlertManagerManualRouting } from './AlertManagerRouting';
export function SimplifiedRouting() {
const { getValues, setValue } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const { getValues } = useFormContext<RuleFormValues>();
const contactPointsInAlert = getValues('contactPoints');
const allAlertManagersByPermission = getAlertManagerDataSourcesByPermission('notification');
@ -54,100 +22,37 @@ export function SimplifiedRouting() {
const alertManagersDataSourcesWithConfigAPI = alertManagersDataSources.filter((am) => am.hasConfigurationAPI);
// we merge the selected contact points with the alert manager meta data
const alertManagersWithSelectedContactPoints = alertManagersDataSourcesWithConfigAPI.map((am) => {
const selectedContactPoint = contactPointsInAlert?.find((cp) => cp.alertManager === am.name);
return { alertManager: am, selectedContactPoint: selectedContactPoint?.selectedContactPoint };
});
// we merge the selected contact points data for each alert manager, with the alert manager meta data
const alertManagersWithSelectedContactPoints = useMemo(
() =>
alertManagersDataSourcesWithConfigAPI.map((am) => {
const selectedContactPoint = contactPointsInAlert ? contactPointsInAlert[am.name] : undefined;
return {
alertManager: am,
selectedContactPoint: selectedContactPoint?.selectedContactPoint ?? '',
routeSettings: {
muteTimeIntervals: selectedContactPoint?.muteTimeIntervals ?? [],
overrideGrouping: selectedContactPoint?.overrideGrouping ?? false,
groupBy: selectedContactPoint?.groupBy ?? [],
overrideTimings: selectedContactPoint?.overrideTimings ?? false,
groupWaitValue: selectedContactPoint?.groupWaitValue ?? '',
groupIntervalValue: selectedContactPoint?.groupIntervalValue ?? '',
repeatIntervalValue: selectedContactPoint?.repeatIntervalValue ?? '',
},
};
}),
[alertManagersDataSourcesWithConfigAPI, contactPointsInAlert]
);
// use reducer to keep this alertManagersWithSelectedContactPoints in the state
const [alertManagersWithCPState, dispatch] = useReducer(receiversReducer, alertManagersWithSelectedContactPoints);
function getContactPointsForForm(alertManagersWithCP: AMContactPoint[]) {
return alertManagersWithCP.map((am) => {
return { alertManager: am.alertManager.name, selectedContactPoint: am.selectedContactPoint };
});
}
// whenever we update the receiversState we have to update the form too
useEffect(() => {
const contactPointsForForm = getContactPointsForForm(alertManagersWithCPState);
setValue('contactPoints', contactPointsForForm, { shouldValidate: false });
}, [alertManagersWithCPState, setValue]);
const shouldShowAM = true;
return alertManagersWithCPState.map((alertManagerContactPoint, index) => {
return alertManagersWithSelectedContactPoints.map((alertManagerContactPoint, index) => {
return (
<div key={index}>
<Stack direction="column">
{shouldShowAM && (
<Stack direction="row" alignItems="center">
<div className={styles.firstAlertManagerLine}></div>
<div className={styles.alertManagerName}>
{' '}
Alert manager:
<img
src={alertManagerContactPoint.alertManager.imgUrl}
alt="Alert manager logo"
className={styles.img}
/>
{alertManagerContactPoint.alertManager.name}
</div>
<div className={styles.secondAlertManagerLine}></div>
</Stack>
)}
<Stack direction="row" gap={1} alignItems="center">
<AlertmanagerProvider
accessType={'notification'}
alertmanagerSourceName={alertManagerContactPoint.alertManager.name}
>
<ContactPointSelector
selectedReceiver={alertManagerContactPoint.selectedContactPoint}
dispatch={dispatch}
alertManager={alertManagerContactPoint.alertManager}
/>
</AlertmanagerProvider>
<LinkToContactPoints />
</Stack>
</Stack>
</div>
<AlertmanagerProvider
accessType={'notification'}
alertmanagerSourceName={alertManagerContactPoint.alertManager.name}
key={alertManagerContactPoint.alertManager.name + index}
>
<AlertManagerManualRouting alertManager={alertManagerContactPoint.alertManager} />
</AlertmanagerProvider>
);
});
}
function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
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="link">Contact points</Text>
<Icon name={'external-link-alt'} size="sm" color="link" />
</Stack>
</Link>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
firstAlertManagerLine: css({
height: 1,
width: theme.spacing(4),
backgroundColor: theme.colors.secondary.main,
}),
alertManagerName: css({
with: 'fit-content',
}),
secondAlertManagerLine: css({
height: '1px',
width: '100%',
flex: 1,
backgroundColor: theme.colors.secondary.main,
}),
img: css({
marginLeft: theme.spacing(2),
width: theme.spacing(3),
height: theme.spacing(3),
marginRight: theme.spacing(1),
}),
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Stack } from '@grafana/ui';
import { ContactPointReceiverTitleRow } from '../../../../contact-points/ContactPoints';
import { RECEIVER_META_KEY, RECEIVER_PLUGIN_META_KEY } from '../../../../contact-points/useContactPoints';
import { ReceiverConfigWithMetadata, getReceiverDescription } from '../../../../contact-points/utils';
interface ContactPointDetailsProps {
receivers: ReceiverConfigWithMetadata[];
}
export const ContactPointDetails = ({ receivers }: ContactPointDetailsProps) => {
return (
<Stack direction="column" gap={0}>
<div>
{receivers.map((receiver, index) => {
const metadata = receiver[RECEIVER_META_KEY];
const pluginMetadata = receiver[RECEIVER_PLUGIN_META_KEY];
const key = metadata.name + index;
return (
<ContactPointReceiverTitleRow
key={key}
name={metadata.name}
type={receiver.type}
description={getReceiverDescription(receiver)}
pluginMetadata={pluginMetadata}
/>
);
})}
</div>
</Stack>
);
};

View File

@ -0,0 +1,83 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { ActionMeta, Field, FieldValidationMessage, InputControl, Select, Stack, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { ContactPointReceiverSummary } from '../../../../contact-points/ContactPoints';
import { ContactPointWithMetadata } from '../../../../contact-points/utils';
export interface ContactPointSelectorProps {
alertManager: string;
contactPoints: ContactPointWithMetadata[];
onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void;
}
export function ContactPointSelector({ alertManager, contactPoints, onSelectContactPoint }: ContactPointSelectorProps) {
const styles = useStyles2(getStyles);
const {
register,
control,
formState: { errors },
watch,
} = useFormContext<RuleFormValues>();
const options = contactPoints.map((receiver) => {
const integrations = receiver?.grafana_managed_receiver_configs;
const description = <ContactPointReceiverSummary receivers={integrations ?? []} />;
return { label: receiver.name, value: receiver, description };
});
const selectedContactPointWithMetadata = options.find(
(option) => option.value.name === watch(`contactPoints.${alertManager}.selectedContactPoint`)
)?.value;
const selectedContactPointSelectableValue = selectedContactPointWithMetadata
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
: undefined;
return (
<Stack direction="column">
<Field
label="Contact point"
{...register(`contactPoints.${alertManager}.selectedContactPoint`, { required: true })}
invalid={!!errors.contactPoints?.[alertManager]?.selectedContactPoint}
>
<InputControl
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<div className={styles.contactPointsSelector}>
<Select
{...field}
defaultValue={selectedContactPointSelectableValue}
aria-label="Contact point"
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
}}
// We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined.
// The regular Select component will render it just fine, but we can't update the typings because SelectableValue
// is shared with other components where the "description" _has_ to be a string.
// I've tried unsuccessfully to separate the typings just I'm giving up :'(
// @ts-ignore
options={options}
width={50}
/>
</div>
{error && <FieldValidationMessage>{'Contact point is required.'}</FieldValidationMessage>}
</>
)}
control={control}
name={`contactPoints.${alertManager}.selectedContactPoint`}
/>
</Field>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
contactPointsSelector: css({
marginTop: theme.spacing(1),
}),
});

View File

@ -0,0 +1,45 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, InputControl, MultiSelect, useStyles2 } from '@grafana/ui';
import { useMuteTimingOptions } from 'app/features/alerting/unified/hooks/useMuteTimingOptions';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { mapMultiSelectValueToStrings } from 'app/features/alerting/unified/utils/amroutes';
import { getFormStyles } from '../../../../notification-policies/formStyles';
export interface MuteTimingFieldsProps {
alertManager: string;
}
export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) {
const styles = useStyles2(getFormStyles);
const {
control,
formState: { errors },
} = useFormContext<RuleFormValues>();
const muteTimingOptions = useMuteTimingOptions();
return (
<Field
label="Mute timings"
data-testid="am-mute-timing-select"
description="Add mute timing to policy"
invalid={!!errors.contactPoints?.[alertManager]?.muteTimeIntervals}
>
<InputControl
render={({ field: { onChange, ref, ...field } }) => (
<MultiSelect
aria-label="Mute timings"
{...field}
className={styles.input}
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
options={muteTimingOptions}
/>
)}
control={control}
name={`contactPoints.${alertManager}.muteTimeIntervals`}
/>
</Field>
);
}

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, FieldValidationMessage, 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';
import {
commonGroupByOptions,
mapMultiSelectValueToStrings,
stringToSelectableValue,
stringsToSelectableValues,
} from 'app/features/alerting/unified/utils/amroutes';
import { getFormStyles } from '../../../../notification-policies/formStyles';
import { TIMING_OPTIONS_DEFAULTS } from '../../../../notification-policies/timingOptions';
import { RouteTimings } from './RouteTimings';
export interface RoutingSettingsProps {
alertManager: string;
}
export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => {
const formStyles = useStyles2(getFormStyles);
const {
control,
watch,
register,
formState: { errors },
} = useFormContext<RuleFormValues>();
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues([]));
const { groupBy, groupIntervalValue, groupWaitValue, repeatIntervalValue } = useGetDefaultsForRoutingSettings();
const overrideGrouping = watch(`contactPoints.${alertManager}.overrideGrouping`);
const overrideTimings = watch(`contactPoints.${alertManager}.overrideTimings`);
return (
<Stack direction="column">
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<Field label="Override grouping">
<Switch id="override-grouping-toggle" {...register(`contactPoints.${alertManager}.overrideGrouping`)} />
</Field>
{!overrideGrouping && (
<Text variant="body" color="secondary">
Grouping: <strong>{groupBy.join(', ')}</strong>
</Text>
)}
</Stack>
{overrideGrouping && (
<Field
label="Group by"
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}
>
<InputControl
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<MultiSelect
aria-label="Group by"
{...field}
allowCustomValue
className={formStyles.input}
onCreateOption={(opt: string) => {
setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]);
// @ts-ignore-check: react-hook-form made me do this
setValue(`contactPoints.${alertManager}.groupBy`, [...field.value, opt]);
}}
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
options={[...commonGroupByOptions, ...groupByOptions]}
/>
{error && <FieldValidationMessage>{'At least one group by option is required'}</FieldValidationMessage>}
</>
)}
name={`contactPoints.${alertManager}.groupBy`}
control={control}
/>
</Field>
)}
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<Field label="Override timings">
<Switch id="override-timings-toggle" {...register(`contactPoints.${alertManager}.overrideTimings`)} />
</Field>
{!overrideTimings && (
<Text variant="body" color="secondary">
Group wait: <strong>{groupWaitValue}, </strong>
Group interval: <strong>{groupIntervalValue}, </strong>
Repeat interval: <strong>{repeatIntervalValue}</strong>
</Text>
)}
</Stack>
{overrideTimings && <RouteTimings alertManager={alertManager} />}
</Stack>
);
};
function useGetDefaultsForRoutingSettings() {
const { selectedAlertmanager } = useAlertmanager();
const { currentData } = useAlertmanagerConfig(selectedAlertmanager);
const config = currentData?.alertmanager_config;
return React.useMemo(() => {
return {
groupWaitValue: TIMING_OPTIONS_DEFAULTS.group_wait,
groupIntervalValue: TIMING_OPTIONS_DEFAULTS.group_interval,
repeatIntervalValue: TIMING_OPTIONS_DEFAULTS.repeat_interval,
groupBy: config?.route?.group_by ?? [],
};
}, [config]);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { promDurationValidator, repeatIntervalValidator } from 'app/features/alerting/unified/utils/amroutes';
import { PromDurationInput } from '../../../../notification-policies/PromDurationInput';
import { getFormStyles } from '../../../../notification-policies/formStyles';
import { routeTimingsFields } from '../../../../notification-policies/routeTimingsFields';
import { TIMING_OPTIONS_DEFAULTS } from '../../../../notification-policies/timingOptions';
interface RouteTimingsProps {
alertManager: string;
}
export function RouteTimings({ alertManager }: RouteTimingsProps) {
const formStyles = useStyles2(getFormStyles);
const {
register,
formState: { errors },
getValues,
} = useFormContext<RuleFormValues>();
return (
<>
<Field
label={routeTimingsFields.groupWait.label}
description={routeTimingsFields.groupWait.description}
invalid={!!errors.contactPoints?.[alertManager]?.groupWaitValue}
error={errors.contactPoints?.[alertManager]?.groupWaitValue?.message}
>
<PromDurationInput
{...register(`contactPoints.${alertManager}.groupWaitValue`, { validate: promDurationValidator })}
aria-label={routeTimingsFields.groupWait.ariaLabel}
className={formStyles.promDurationInput}
placeholder={TIMING_OPTIONS_DEFAULTS.group_wait}
/>
</Field>
<Field
label={routeTimingsFields.groupInterval.label}
description={routeTimingsFields.groupInterval.description}
invalid={!!errors.contactPoints?.[alertManager]?.groupIntervalValue}
error={errors.contactPoints?.[alertManager]?.groupIntervalValue?.message}
>
<PromDurationInput
{...register(`contactPoints.${alertManager}.groupIntervalValue`, {
validate: promDurationValidator,
})}
aria-label={routeTimingsFields.groupInterval.ariaLabel}
className={formStyles.promDurationInput}
placeholder={TIMING_OPTIONS_DEFAULTS.group_interval}
/>
</Field>
<Field
label={routeTimingsFields.repeatInterval.label}
description={routeTimingsFields.repeatInterval.description}
invalid={!!errors.contactPoints?.[alertManager]?.repeatIntervalValue}
error={errors.contactPoints?.[alertManager]?.repeatIntervalValue?.message}
>
<PromDurationInput
{...register(`contactPoints.${alertManager}.repeatIntervalValue`, {
validate: (value: string) => {
const groupInterval = getValues(`contactPoints.${alertManager}.repeatIntervalValue`);
return repeatIntervalValidator(value, groupInterval);
},
})}
aria-label={routeTimingsFields.repeatInterval.ariaLabel}
className={formStyles.promDurationInput}
placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval}
/>
</Field>
</>
);
}

View File

@ -8,9 +8,20 @@ export enum RuleFormType {
cloudRecording = 'cloud-recording',
}
export interface ContactPoints {
alertManager: string;
selectedContactPoint?: string;
export interface ContactPoint {
selectedContactPoint: string;
overrideGrouping: boolean;
groupBy: string[];
overrideTimings: boolean;
groupWaitValue: string;
groupIntervalValue: string;
repeatIntervalValue: string;
muteTimeIntervals: string[];
}
// key: name of alert manager, value ContactPoint
export interface AlertManagerManualRouting {
[key: string]: ContactPoint;
}
export interface RuleFormValues {
@ -32,8 +43,8 @@ export interface RuleFormValues {
evaluateEvery: string;
evaluateFor: string;
isPaused?: boolean;
contactPoints?: ContactPoints[];
manualRouting: boolean;
manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule
contactPoints?: AlertManagerManualRouting;
// cortex / loki rules
namespace: string;

View File

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

View File

@ -68,7 +68,10 @@ export const getDefaultFormValues = (): RuleFormValues => {
evaluateFor: '5m',
evaluateEvery: MINUTE,
manualRouting: false, // let's decide this later
contactPoints: [],
contactPoints: {},
overrideGrouping: false,
overrideTimings: false,
muteTimeIntervals: [],
// cortex / loki
namespace: '',
@ -136,7 +139,8 @@ export function normalizeDefaultAnnotations(annotations: Array<{ key: string; va
}
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused } = values;
const { name, condition, noDataState, execErrState, evaluateFor, queries, isPaused, contactPoints, manualRouting } =
values;
if (condition) {
return {
grafana_alert: {
@ -146,6 +150,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
exec_err_state: execErrState,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
contactPoints: manualRouting ? contactPoints : undefined,
},
for: evaluateFor,
annotations: arrayToRecord(values.annotations || []),
@ -178,8 +183,26 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
labels: listifyLabelsOrAnnotations(rule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
// manualrouting: ?? //todo depending on the implementation of the manual routing
// contactPoints: ?? //todo depending on the implementation of the manual routing
contactPoints: ga.contactPoints,
manualRouting: Boolean(ga.contactPoints),
// next line is for testing
// manualRouting: true,
// contactPoints: {
// grafana: {
// selectedContactPoint: "contact_point_5",
// muteTimeIntervals: [
// "mute timing 1"
// ],
// overrideGrouping: true,
// overrideTimings: true,
// "groupBy": [
// "..."
// ],
// groupWaitValue: "35s",
// groupIntervalValue: "6m",
// repeatIntervalValue: "5h"
// }
// }
};
} else {
throw new Error('Unexpected type of rule for grafana rules source');

View File

@ -1,6 +1,7 @@
// 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';
@ -205,6 +206,7 @@ export interface PostableGrafanaRuleDefinition {
exec_err_state: GrafanaAlertStateDecision;
data: AlertQuery[];
is_paused?: boolean;
contactPoints?: AlertManagerManualRouting;
}
export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
id?: string;