Alerting: Simplify routing in alert form - part1 (#78040)

* Add routing option tabs

* Use alertingSimplifiedRouting feature toggle

* Move simplified routing tab to a separate component:SimplifiedRouting

* Populate contact point selector with the right values

* Show alert manager icons

* Fix descriptions

* Remove clear button on ContactPointSelector and save updated reducer state in the form

* Load contact points and manual option from rule data in RuleFormValues

* make contact point selector not clearable

* Refactor

* Add link to contact points view

* Move ContactPointSelector to a separate file

* Refactor: move hoook useReceiversMetadataMapByName to a separate file

* Update Need more info texts

* Address some PR review comments

* Use useContactPointsWithStatus hook and wrap each ContacPointSelector with AlertmanagerProvider

* use getAlertManagerDataSourcesByPermission instead of useGetAlertManagersMetadata in NotificationPreview

* Update enum

* Remove css style

* remove console

* update contact point selector

* file cleanup

* adds summary as description

* Update text in manual tab

* Fix preview routing not checking if alert manager can handle grafana alerts

* Fix typo

* remove unused location form field

* fix prettier

* fix test

* Remove unused location form field from AlertRuleNameInput

* Only use internal AlertManager for now

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar 2023-11-22 10:15:33 +01:00 committed by GitHub
parent 72759be6ec
commit 2acf153a26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 642 additions and 246 deletions

View File

@ -2266,16 +2266,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
],
"public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]

View File

@ -10,7 +10,9 @@ const fetch = jest.fn();
jest.mock('./prometheus');
jest.mock('./ruler');
jest.mock('app/core/services/context_srv', () => {});
jest.mock('app/core/services/context_srv', () => ({
contextSrv: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch }),

View File

@ -325,7 +325,7 @@ export const ContactPoint = ({
})}
</div>
) : (
<div>
<div className={styles.integrationWrapper}>
<ContactPointReceiverSummary receivers={receivers} />
</div>
)}
@ -493,35 +493,32 @@ type ContactPointReceiverSummaryProps = {
* This summary is used when we're dealing with non-Grafana managed alertmanager since they
* don't have any metadata worth showing other than a summary of what types are configured for the contact point
*/
const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
const styles = useStyles2(getStyles);
export const ContactPointReceiverSummary = ({ receivers }: ContactPointReceiverSummaryProps) => {
const countByType = groupBy(receivers, (receiver) => receiver.type);
return (
<div className={styles.integrationWrapper}>
<Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}>
{Object.entries(countByType).map(([type, receivers], index) => {
const iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index;
<Stack direction="column" gap={0}>
<Stack direction="row" alignItems="center" gap={1}>
{Object.entries(countByType).map(([type, receivers], index) => {
const iconName = INTEGRATION_ICONS[type];
const receiverName = receiverTypeNames[type] ?? upperFirst(type);
const isLastItem = size(countByType) - 1 === index;
return (
<React.Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
<Text variant="body" color="primary">
{receiverName}
{receivers.length > 1 && <> ({receivers.length})</>}
</Text>
</Stack>
{!isLastItem && '⋅'}
</React.Fragment>
);
})}
</Stack>
return (
<React.Fragment key={type}>
<Stack direction="row" alignItems="center" gap={0.5}>
{iconName && <Icon name={iconName} />}
<Text variant="body">
{receiverName}
{receivers.length > 1 && <> ({receivers.length})</>}
</Text>
</Stack>
{!isLastItem && '⋅'}
</React.Fragment>
);
})}
</Stack>
</div>
</Stack>
);
};

View File

@ -18,7 +18,7 @@ export const AlertRuleNameInput = () => {
register,
watch,
formState: { errors },
} = useFormContext<RuleFormValues & { location?: string }>();
} = useFormContext<RuleFormValues>();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';

View File

@ -3,19 +3,7 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Button,
Field,
InlineLabel,
Label,
useStyles2,
Text,
Tooltip,
Icon,
Input,
LoadingPlaceholder,
Stack,
} from '@grafana/ui';
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
@ -23,6 +11,8 @@ import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form';
import AlertLabelDropdown from '../AlertLabelDropdown';
import { NeedHelpInfo } from './NeedHelpInfo';
interface Props {
className?: string;
dataSourceName?: string | null;
@ -271,23 +261,20 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => {
return (
<div>
<Label description="A set of default labels is automatically added. Add additional labels as required.">
<Stack gap={0.5} alignItems="center">
<Text variant="bodySmall" color="primary">
Labels
<Stack direction="column" gap={1}>
<Text element="h5">Labels</Text>
<Stack direction={'row'} gap={1}>
<Text variant="bodySmall" color="secondary">
Add labels to your rule to annotate your rules, ease searching, or route to a notification policy.
</Text>
<Tooltip
content={
<div>
The dropdown only displays labels that you have previously used for alerts. Select a label from the
dropdown or type in a new one.
</div>
}
>
<Icon className={styles.icon} name="info-circle" size="sm" />
</Tooltip>
<NeedHelpInfo
contentText="The dropdown only displays labels that you have previously used for alerts.
Select a label from the options below or type in a new one."
title="Labels"
/>
</Stack>
</Label>
</Stack>
<div className={styles.labelsContainer}></div>
{dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
</div>
);
@ -295,47 +282,48 @@ const LabelsField: FC<Props> = ({ dataSourceName }) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
icon: css`
margin-right: ${theme.spacing(0.5)};
`,
flexColumn: css`
display: flex;
flex-direction: column;
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
& + button {
margin-left: ${theme.spacing(0.5)};
}
`,
deleteLabelButton: css`
margin-left: ${theme.spacing(0.5)};
align-self: flex-start;
`,
addLabelButton: css`
flex-grow: 0;
align-self: flex-start;
`,
centerAlignRow: css`
align-items: baseline;
`,
equalSign: css`
align-self: flex-start;
width: 28px;
justify-content: center;
margin-left: ${theme.spacing(0.5)};
`,
labelInput: css`
width: 175px;
margin-bottom: -${theme.spacing(1)};
& + & {
margin-left: ${theme.spacing(1)};
}
`,
icon: css({
marginRight: theme.spacing(0.5),
}),
flexColumn: css({
display: 'flex',
flexDirection: 'column',
}),
flexRow: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
'& + button': {
marginLeft: theme.spacing(0.5),
},
}),
deleteLabelButton: css({
marginLeft: theme.spacing(0.5),
alignSelf: 'flex-start',
}),
addLabelButton: css({
flexGrow: 0,
alignSelf: 'flex-start',
}),
centerAlignRow: css({
alignItems: 'baseline',
}),
equalSign: css({
alignSelf: 'flex-start',
width: '28px',
justifyContent: 'center',
marginLeft: theme.spacing(0.5),
}),
labelInput: css({
width: '175px',
marginBottom: `-${theme.spacing(1)}`,
'& + &': {
marginLeft: theme.spacing(1),
},
}),
labelsContainer: css({
marginBottom: theme.spacing(3),
}),
};
};

View File

@ -1,7 +1,10 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Icon, Text, Stack } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Icon, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@ -9,80 +12,53 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import LabelsField from './LabelsField';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection';
import { SimplifiedRouting } from './alert-rule-form/simplifiedRouting/SimplifiedRouting';
import { NotificationPreview } from './notificaton-preview/NotificationPreview';
type NotificationsStepProps = {
alertUid?: string;
};
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const [type, labels, queries, condition, folder, alertName] = watch([
enum RoutingOptions {
NotificationPolicy = 'notification policy',
ContactPoint = 'contact point',
}
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const { watch, setValue } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const [type, labels, queries, condition, folder, alertName, manualRouting] = watch([
'type',
'labels',
'queries',
'condition',
'folder',
'name',
'manualRouting',
]);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const shouldRenderPreview = type === RuleFormType.grafana;
const NotificationsStepDescription = () => {
return (
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Add custom labels to change the way your notifications are routed.
</Text>
<NeedHelpInfo
contentText={
<Stack gap={1}>
<Stack direction="row" gap={0}>
<>
Firing alert rule instances are routed to notification policies based on matching labels. All alert
rules and instances, irrespective of their labels, match the default notification policy. If there are
no nested policies, or no nested policies match the labels in the alert rule or alert instance, then
the default notification policy is the matching policy.
</>
<a
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`}
target="_blank"
rel="noreferrer"
>
<Text color="link">
Read about notification routing. <Icon name="external-link-alt" />
</Text>
</a>
</Stack>
<Stack direction="row" gap={0}>
<>
Custom labels change the way your notifications are routed. First, add labels to your alert rule and
then connect them to your notification policy by adding label matchers.
</>
<a
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/`}
target="_blank"
rel="noreferrer"
>
<Text color="link">
Read about Labels and annotations. <Icon name="external-link-alt" />
</Text>
</a>
</Stack>
</Stack>
}
title="Notification routing"
/>
</Stack>
);
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 shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled;
return (
<RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 4 : 5}
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure notifications'}
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Labels and notifications'}
description={
<Stack direction="row" gap={0.5} alignItems="baseline">
{type === RuleFormType.cloudRecording ? (
@ -90,23 +66,198 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
Add labels to help you better manage your recording rules
</Text>
) : (
<NotificationsStepDescription />
shouldAllowSimplifiedRouting && (
<Text variant="bodySmall" color="secondary">
Select who should receive a notification when an alert rule fires.
</Text>
)
)}
</Stack>
}
fullWidth
>
<LabelsField dataSourceName={dataSourceName} />
{shouldRenderPreview && (
<NotificationPreview
alertQueries={queries}
customLabels={labels}
condition={condition}
folder={folder}
alertName={alertName}
alertUid={alertUid}
/>
{shouldAllowSimplifiedRouting && (
<div className={styles.configureNotifications}>
<Text element="h5">Configure notifications</Text>
<Text variant="bodySmall" color="secondary">
Select who should receive a notification when an alert rule fires.
</Text>
</div>
)}
<RuleEditorSectionBody />
</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>
);
}
};
// Auxiliar components to build the texts and descriptions in the NotificationsStep
function NeedHelpInfoForNotificationPolicy() {
return (
<NeedHelpInfo
contentText={
<Stack gap={1} direction="column">
<Stack direction="column" gap={0}>
<>
Firing alert rule instances are routed to notification policies based on matching labels. All alert rules
and instances, irrespective of their labels, match the default notification policy. If there are no nested
policies, or no nested policies match the labels in the alert rule or alert instance, then the default
notification policy is the matching policy.
</>
<a
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`}
target="_blank"
rel="noreferrer"
>
<Text color="link">
Read about notification routing. <Icon name="external-link-alt" />
</Text>
</a>
</Stack>
<Stack direction="column" gap={0}>
<>
Custom labels change the way your notifications are routed. First, add labels to your alert rule and then
connect them to your notification policy by adding label matchers.
</>
<a
href={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/`}
target="_blank"
rel="noreferrer"
>
<Text color="link">
Read about Labels and annotations. <Icon name="external-link-alt" />
</Text>
</a>
</Stack>
</Stack>
}
title="Notification routing"
/>
);
}
function NeedHelpInfoForContactpoint() {
return (
<NeedHelpInfo
contentText={
<>
Select a contact point to notify all recipients in it.
<br />
<br />
Notifications for firing alert instances are grouped based on folder and alert rule name.
<br />
The waiting time until the initial notification is sent for a new group created by an incoming alert is 30
seconds.
<br />
The waiting time to send a batch of new alerts for that group after the first notification was sent is 5
minutes.
<br />
The waiting time to resend an alert after they have successfully been sent is 4 hours.
<br />
Grouping and wait time values are defined in your default notification policy.
</>
}
// todo: update the link with the new documentation about simplified routing
externalLink="`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`"
linkText="Read more about notifiying contact points"
title="Notify contact points"
/>
);
}
interface NotificationsStepDescriptionProps {
manualRouting: boolean;
}
export const RoutingOptionDescription = ({ manualRouting }: NotificationsStepDescriptionProps) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.notificationsOptionDescription}>
<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.'}
</Text>
{manualRouting ? <NeedHelpInfoForContactpoint /> : <NeedHelpInfoForNotificationPolicy />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
routingOptions: css({
marginTop: theme.spacing(2),
width: 'fit-content',
}),
simplifiedRouting: css({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(2),
}),
configureNotifications: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
marginTop: theme.spacing(2),
}),
notificationsOptionDescription: css({
marginTop: theme.spacing(1),
display: 'flex',
flexDirection: 'row',
alignItems: 'baseline',
gap: theme.spacing(0.5),
}),
});

View File

@ -7,7 +7,7 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { RuleEditorSection } from './RuleEditorSection';
export function RecordingRulesNameSpaceAndGroupStep() {
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const { watch } = useFormContext<RuleFormValues>();
const dataSourceName = watch('dataSourceName');

View File

@ -0,0 +1,65 @@
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.v2';
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

@ -0,0 +1,153 @@
import { css } from '@emotion/css';
import { createAction, createReducer } from '@reduxjs/toolkit';
import React, { useEffect, useReducer } 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 { 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);
}
});
});
export function SimplifiedRouting() {
const { getValues, setValue } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const contactPointsInAlert = getValues('contactPoints');
const allAlertManagersByPermission = getAlertManagerDataSourcesByPermission('notification');
// We decided to only show internal alert manager for now. Once we want to show external alert managers we can use this code
// const alertManagersDataSources = allAlertManagersByPermission.availableInternalDataSources.concat(
// allAlertManagersByPermission.availableExternalDataSources
// );
const alertManagersDataSources = allAlertManagersByPermission.availableInternalDataSources;
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 };
});
// 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 (
<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>
);
});
}
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

@ -13,30 +13,28 @@ import { mockApi, setupMswServer } from '../../../mockApi';
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi';
import * as dataSource from '../../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import {
AlertManagerDataSource,
GRAFANA_RULES_SOURCE_NAME,
useGetAlertManagerDataSourcesByPermissionAndConfig,
} from '../../../utils/datasource';
import { Folder } from '../RuleFolderPicker';
import { NotificationPreview } from './NotificationPreview';
import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager';
import * as notificationPreview from './useGetAlertManagersSourceNamesAndImage';
import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage';
jest.mock('../../../useRouteGroupsMatcher');
jest
.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage')
.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]);
jest.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage').mockReturnValue([
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
]);
.spyOn(dataSource, 'useGetAlertManagerDataSourcesByPermissionAndConfig')
.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, imgUrl: '', hasConfigurationAPI: true }]);
jest.spyOn(dataSource, 'getDatasourceAPIUid').mockImplementation((ds: string) => ds);
const useGetAlertManagersSourceNamesAndImageMock = useGetAlertManagersSourceNamesAndImage as jest.MockedFunction<
typeof useGetAlertManagersSourceNamesAndImage
>;
const getAlertManagerDataSourcesByPermissionAndConfigMock =
useGetAlertManagerDataSourcesByPermissionAndConfig as jest.MockedFunction<
typeof useGetAlertManagerDataSourcesByPermissionAndConfig
>;
const ui = {
route: byTestId('matching-policy-route'),
@ -62,8 +60,14 @@ beforeEach(() => {
const alertQuery = mockAlertQuery({ datasourceUid: 'whatever', refId: 'A' });
const grafanaAlertManagerDataSource: AlertManagerDataSource = {
name: GRAFANA_RULES_SOURCE_NAME,
imgUrl: '',
hasConfigurationAPI: true,
};
function mockOneAlertManager() {
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }]);
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([grafanaAlertManagerDataSource]);
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
@ -79,10 +83,11 @@ function mockOneAlertManager() {
}
function mockTwoAlertManagers() {
useGetAlertManagersSourceNamesAndImageMock.mockReturnValue([
{ name: GRAFANA_RULES_SOURCE_NAME, img: '' },
{ name: 'OTHER_AM', img: '' },
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([
{ name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
grafanaAlertManagerDataSource,
]);
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.withRoute((routeBuilder) =>
@ -272,7 +277,7 @@ describe('NotificationPreviewByAlertmanager', () => {
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,
@ -327,7 +332,7 @@ describe('NotificationPreviewByAlertmanager', () => {
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,
@ -382,7 +387,7 @@ describe('NotificationPreviewByAlertmanager', () => {
render(
<NotificationPreviewByAlertManager
alertManagerSource={{ name: GRAFANA_RULES_SOURCE_NAME, img: '' }}
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>,

View File

@ -3,15 +3,14 @@ import { compact } from 'lodash';
import React, { lazy, Suspense } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, LoadingPlaceholder, useStyles2, Text } from '@grafana/ui';
import { Button, LoadingPlaceholder, Text, useStyles2 } from '@grafana/ui';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
import { Folder } from '../RuleFolderPicker';
import { useGetAlertManagersSourceNamesAndImage } from './useGetAlertManagersSourceNamesAndImage';
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
interface NotificationPreviewProps {
@ -63,10 +62,10 @@ export const NotificationPreview = ({
});
};
// Get list of alert managers source name + image
const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage();
// Get alert managers's data source information
const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification');
const onlyOneAM = alertManagerSourceNamesAndImage.length === 1;
const onlyOneAM = alertManagerDataSources.length === 1;
return (
<Stack direction="column">
@ -98,7 +97,7 @@ export const NotificationPreview = ({
</div>
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}>
{alertManagerSourceNamesAndImage.map((alertManagerSource) => (
{alertManagerDataSources.map((alertManagerSource) => (
<NotificationPreviewByAlertManager
alertManagerSource={alertManagerSource}
potentialInstances={potentialInstances}

View File

@ -6,17 +6,17 @@ import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafa
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { AlertManagerDataSource } from '../../../utils/datasource';
import { NotificationRoute } from './NotificationRoute';
import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview';
import { AlertManagerNameWithImage } from './useGetAlertManagersSourceNamesAndImage';
function NotificationPreviewByAlertManager({
alertManagerSource,
potentialInstances,
onlyOneAM,
}: {
alertManagerSource: AlertManagerNameWithImage;
alertManagerSource: AlertManagerDataSource;
potentialInstances: Labels[];
onlyOneAM: boolean;
}) {
@ -49,7 +49,7 @@ function NotificationPreviewByAlertManager({
<div className={styles.alertManagerName}>
{' '}
Alert manager:
<img src={alertManagerSource.img} alt="" className={styles.img} />
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
{alertManagerSource.name}
</div>
<div className={styles.secondAlertManagerLine}></div>

View File

@ -1,28 +0,0 @@
import { AlertmanagerChoice } from '../../../../../../plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { getExternalDsAlertManagers, GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
export interface AlertManagerNameWithImage {
name: string;
img: string;
}
export const useGetAlertManagersSourceNamesAndImage = () => {
//get current alerting config
const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined);
const externalDsAlertManagers = getExternalDsAlertManagers().map((ds) => ({
name: ds.name,
img: ds.meta.info.logos.small,
}));
const alertmanagerChoice = amConfigStatus?.alertmanagersChoice;
const alertManagerSourceNamesWithImage: AlertManagerNameWithImage[] =
alertmanagerChoice === AlertmanagerChoice.Internal
? [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }]
: alertmanagerChoice === AlertmanagerChoice.External
? externalDsAlertManagers
: [{ name: GRAFANA_RULES_SOURCE_NAME, img: 'public/img/grafana_icon.svg' }, ...externalDsAlertManagers];
return alertManagerSourceNamesWithImage;
};

View File

@ -18,7 +18,7 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
formState: { errors },
setValue,
watch,
} = useFormContext<RuleFormValues & { location?: string }>();
} = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const ruleFormType = watch('type');
@ -40,8 +40,6 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
{...field}
disabled={disabled}
onChange={(ds: DataSourceInstanceSettings) => {
// reset location if switching data sources, as different rules source will have different groups and namespaces
setValue('location', undefined);
// reset expression as they don't need to persist after changing datasources
setValue('expression', '');
onChange(ds?.name ?? null);

View File

@ -29,7 +29,9 @@ const externalAmMimir: AlertManagerDataSource = {
describe('useAlertmanager', () => {
it('Should return undefined alert manager name when there are no available alert managers', () => {
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]);
jest
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
.mockReturnValueOnce({ availableExternalDataSources: [], availableInternalDataSources: [] });
const wrapper = ({ children }: React.PropsWithChildren) => (
<MemoryRouter>
<AlertmanagerProvider accessType="instance">{children}</AlertmanagerProvider>
@ -41,7 +43,11 @@ describe('useAlertmanager', () => {
});
it('Should return Grafana AM when it is available and no alert manager query param exists', () => {
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([grafanaAm]);
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce({
availableExternalDataSources: [],
availableInternalDataSources: [{ name: GRAFANA_RULES_SOURCE_NAME, imgUrl: '', hasConfigurationAPI: true }],
});
const wrapper = ({ children }: React.PropsWithChildren) => (
<MemoryRouter>
<AlertmanagerProvider accessType="instance">{children}</AlertmanagerProvider>
@ -53,7 +59,9 @@ describe('useAlertmanager', () => {
});
it('Should return alert manager included in the query param when available', () => {
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]);
jest
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
.mockReturnValueOnce({ availableExternalDataSources: [externalAmProm], availableInternalDataSources: [] });
const history = createMemoryHistory();
history.push({ search: `alertmanager=${externalAmProm.name}` });
@ -69,7 +77,9 @@ describe('useAlertmanager', () => {
});
it('Should return undefined if alert manager included in the query is not available', () => {
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([]);
jest
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
.mockReturnValueOnce({ availableExternalDataSources: [], availableInternalDataSources: [] });
const history = createMemoryHistory();
history.push({ search: `alertmanager=Not available external AM` });
@ -85,7 +95,9 @@ describe('useAlertmanager', () => {
});
it('Should return alert manager from store if available and query is empty', () => {
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce([externalAmProm]);
jest
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
.mockReturnValueOnce({ availableExternalDataSources: [externalAmProm], availableInternalDataSources: [] });
const wrapper = ({ children }: React.PropsWithChildren) => (
<MemoryRouter>
@ -100,9 +112,10 @@ describe('useAlertmanager', () => {
});
it('Should prioritize the alert manager from query over store', () => {
jest
.spyOn(useAlertManagerSources, 'useAlertManagersByPermission')
.mockReturnValueOnce([externalAmProm, externalAmMimir]);
jest.spyOn(useAlertManagerSources, 'useAlertManagersByPermission').mockReturnValueOnce({
availableExternalDataSources: [externalAmProm, externalAmMimir],
availableInternalDataSources: [],
});
const history = createMemoryHistory();
history.push({ search: `alertmanager=${externalAmProm.name}` });

View File

@ -8,8 +8,8 @@ import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants';
import {
AlertManagerDataSource,
getAlertmanagerDataSourceByName,
GRAFANA_RULES_SOURCE_NAME,
getAlertmanagerDataSourceByName,
} from '../utils/datasource';
interface Context {
@ -31,7 +31,10 @@ interface Props extends React.PropsWithChildren {
const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: Props) => {
const [queryParams, updateQueryParams] = useQueryParams();
const availableAlertManagers = useAlertManagersByPermission(accessType);
const allAvailableAlertManagers = useAlertManagersByPermission(accessType);
const availableAlertManagers = allAvailableAlertManagers.availableInternalDataSources.concat(
allAvailableAlertManagers.availableExternalDataSources
);
const updateSelectedAlertmanager = React.useCallback(
(selectedAlertManager: string) => {

View File

@ -8,6 +8,11 @@ export enum RuleFormType {
cloudRecording = 'cloud-recording',
}
export interface ContactPoints {
alertManager: string;
selectedContactPoint?: string;
}
export interface RuleFormValues {
// common
name: string;
@ -27,6 +32,8 @@ export interface RuleFormValues {
evaluateEvery: string;
evaluateFor: string;
isPaused?: boolean;
contactPoints?: ContactPoints[];
manualRouting: boolean;
// cortex / loki rules
namespace: string;

View File

@ -1,10 +1,18 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import {
AlertManagerDataSourceJsonData,
AlertManagerImplementation,
AlertmanagerChoice,
} from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { RulesSource } from 'app/types/unified-alerting';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext';
import { instancesPermissions, notificationsPermissions } from './access-control';
import { getAllDataSources } from './config';
@ -21,6 +29,8 @@ export interface AlertManagerDataSource {
name: string;
imgUrl: string;
meta?: DataSourceInstanceSettings['meta'];
hasConfigurationAPI?: boolean;
handleGrafanaManagedAlerts?: boolean;
}
export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus];
@ -54,6 +64,7 @@ export function getExternalDsAlertManagers() {
const grafanaAlertManagerDataSource: AlertManagerDataSource = {
name: GRAFANA_RULES_SOURCE_NAME,
imgUrl: 'public/img/grafana_icon.svg',
hasConfigurationAPI: true,
};
// Used only as a fallback for Alert Group plugin
@ -69,17 +80,53 @@ export function getAllAlertManagerDataSources(): AlertManagerDataSource[] {
];
}
export function getAlertManagerDataSourcesByPermission(
/**
* This method gets all alert managers that the user has access, and then filter them first by being able to handle grafana managed alerts,
* and then depending on the current alerting configuration returns either only the internal alert managers, only the external alert managers, or both.
*
*/
export function useGetAlertManagerDataSourcesByPermissionAndConfig(
permission: 'instance' | 'notification'
): AlertManagerDataSource[] {
const availableDataSources: AlertManagerDataSource[] = [];
const allAlertManagersByPermission = useAlertManagersByPermission(permission); // this hook memoizes the result of getAlertManagerDataSourcesByPermission
const externalDsAlertManagers: AlertManagerDataSource[] =
allAlertManagersByPermission.availableExternalDataSources.filter((ds) => ds.handleGrafanaManagedAlerts);
const internalDSAlertManagers = allAlertManagersByPermission.availableInternalDataSources;
//get current alerting configuration
const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined);
const alertmanagerChoice = amConfigStatus?.alertmanagersChoice;
switch (alertmanagerChoice) {
case AlertmanagerChoice.Internal:
return internalDSAlertManagers;
case AlertmanagerChoice.External:
return externalDsAlertManagers;
default:
return [...internalDSAlertManagers, ...externalDsAlertManagers];
}
}
/**
* This method gets all alert managers that the user has access to and then split them into two groups:
* 1. Internal alert managers
* 2. External alert managers
*/
export function getAlertManagerDataSourcesByPermission(permission: 'instance' | 'notification'): {
availableInternalDataSources: AlertManagerDataSource[];
availableExternalDataSources: AlertManagerDataSource[];
} {
const availableInternalDataSources: AlertManagerDataSource[] = [];
const availableExternalDataSources: AlertManagerDataSource[] = [];
const permissions = {
instance: instancesPermissions.read,
notification: notificationsPermissions.read,
};
if (contextSrv.hasPermission(permissions[permission].grafana)) {
availableDataSources.push(grafanaAlertManagerDataSource);
availableInternalDataSources.push(grafanaAlertManagerDataSource);
}
if (contextSrv.hasPermission(permissions[permission].external)) {
@ -88,11 +135,13 @@ export function getAlertManagerDataSourcesByPermission(
displayName: ds.name,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
hasConfigurationAPI: isAlertManagerWithConfigAPI(ds.jsonData),
handleGrafanaManagedAlerts: ds.jsonData.handleGrafanaManagedAlerts,
}));
availableDataSources.push(...cloudSources);
availableExternalDataSources.push(...cloudSources);
}
return availableDataSources;
return { availableInternalDataSources, availableExternalDataSources };
}
export function getLotexDataSourceByName(dataSourceName: string): DataSourceInstanceSettings {

View File

@ -67,6 +67,8 @@ export const getDefaultFormValues = (): RuleFormValues => {
execErrState: GrafanaAlertStateDecision.Error,
evaluateFor: '5m',
evaluateEvery: MINUTE,
manualRouting: false, // let's decide this later
contactPoints: [],
// cortex / loki
namespace: '',
@ -176,6 +178,8 @@ 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
};
} else {
throw new Error('Unexpected type of rule for grafana rules source');