diff --git a/.betterer.results b/.betterer.results
index f2a8a36ed1c..0aa59dc2050 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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"]
diff --git a/public/app/features/alerting/unified/api/buildInfo.test.ts b/public/app/features/alerting/unified/api/buildInfo.test.ts
index 5bc0e7f60a2..53f9319364a 100644
--- a/public/app/features/alerting/unified/api/buildInfo.test.ts
+++ b/public/app/features/alerting/unified/api/buildInfo.test.ts
@@ -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 }),
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
index fc92842834d..c6e8cc2fe97 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v2.tsx
@@ -325,7 +325,7 @@ export const ContactPoint = ({
})}
) : (
-
+
)}
@@ -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 (
-
-
-
- {Object.entries(countByType).map(([type, receivers], index) => {
- const iconName = INTEGRATION_ICONS[type];
- const receiverName = receiverTypeNames[type] ?? upperFirst(type);
- const isLastItem = size(countByType) - 1 === index;
+
+
+ {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 (
-
-
- {iconName && }
-
- {receiverName}
- {receivers.length > 1 && <> ({receivers.length})>}
-
-
- {!isLastItem && '⋅'}
-
- );
- })}
-
+ return (
+
+
+ {iconName && }
+
+ {receiverName}
+ {receivers.length > 1 && <> ({receivers.length})>}
+
+
+ {!isLastItem && '⋅'}
+
+ );
+ })}
-
+
);
};
diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx
index 5849565febb..f96d308e08e 100644
--- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx
@@ -18,7 +18,7 @@ export const AlertRuleNameInput = () => {
register,
watch,
formState: { errors },
- } = useFormContext
();
+ } = useFormContext();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
diff --git a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx
index fdeec0525ab..49e01b94660 100644
--- a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx
@@ -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 = ({ dataSourceName }) => {
return (
-
-
-
- Labels
+
+ Labels
+
+
+ Add labels to your rule to annotate your rules, ease searching, or route to a notification policy.
-
- The dropdown only displays labels that you have previously used for alerts. Select a label from the
- dropdown or type in a new one.
-
- }
- >
-
-
+
-
+
+
{dataSourceName ? : }
);
@@ -295,47 +282,48 @@ const LabelsField: FC = ({ 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),
+ }),
};
};
diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
index 8f141328cc5..66389b91baf 100644
--- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
@@ -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();
- 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();
+ 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 (
-
-
- Add custom labels to change the way your notifications are routed.
-
-
-
- <>
- 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.
- >
-
-
- Read about notification routing.
-
-
-
-
- <>
- 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.
- >
-
-
- Read about Labels and annotations.
-
-
-
-
- }
- title="Notification routing"
- />
-
- );
+ 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 (
{type === RuleFormType.cloudRecording ? (
@@ -90,23 +66,198 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
Add labels to help you better manage your recording rules
) : (
-
+ shouldAllowSimplifiedRouting && (
+
+ Select who should receive a notification when an alert rule fires.
+
+ )
)}
}
fullWidth
>
- {shouldRenderPreview && (
-
+ {shouldAllowSimplifiedRouting && (
+
+ Configure notifications
+
+ Select who should receive a notification when an alert rule fires.
+
+
)}
+
);
+
+ /**
+ * 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 && (
+
+ )}
+ >
+ );
+ }
+ return (
+
+
+
+
+
+
+
+ {manualRouting ? (
+
+
+
+ ) : (
+ shouldRenderPreview && (
+
+ )
+ )}
+
+ );
+ }
};
+
+// Auxiliar components to build the texts and descriptions in the NotificationsStep
+function NeedHelpInfoForNotificationPolicy() {
+ return (
+
+
+ <>
+ 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.
+ >
+
+
+ Read about notification routing.
+
+
+
+
+ <>
+ 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.
+ >
+
+
+ Read about Labels and annotations.
+
+
+
+
+ }
+ title="Notification routing"
+ />
+ );
+}
+
+function NeedHelpInfoForContactpoint() {
+ return (
+
+ Select a contact point to notify all recipients in it.
+
+
+ Notifications for firing alert instances are grouped based on folder and alert rule name.
+
+ The waiting time until the initial notification is sent for a new group created by an incoming alert is 30
+ seconds.
+
+ The waiting time to send a batch of new alerts for that group after the first notification was sent is 5
+ minutes.
+
+ The waiting time to resend an alert after they have successfully been sent is 4 hours.
+
+ 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 (
+
+
+ {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.'}
+
+ {manualRouting ? : }
+
+ );
+};
+
+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),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/RecordingRulesNameSpaceAndGroupStep.tsx b/public/app/features/alerting/unified/components/rule-editor/RecordingRulesNameSpaceAndGroupStep.tsx
index 04bc6d5e89e..ad1af3184cd 100644
--- a/public/app/features/alerting/unified/components/rule-editor/RecordingRulesNameSpaceAndGroupStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/RecordingRulesNameSpaceAndGroupStep.tsx
@@ -7,7 +7,7 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { RuleEditorSection } from './RuleEditorSection';
export function RecordingRulesNameSpaceAndGroupStep() {
- const { watch } = useFormContext();
+ const { watch } = useFormContext();
const dataSourceName = watch('dataSourceName');
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/ContactPointSelector.tsx
new file mode 100644
index 00000000000..09fa89ce112
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/ContactPointSelector.tsx
@@ -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;
+}
+export function ContactPointSelector({ selectedReceiver, alertManager, dispatch }: ContactPointSelectorProps) {
+ const styles = useStyles2(getStyles);
+ const onChange = (value: SelectableValue) => {
+ 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 = ;
+
+ return { label: receiver.name, value: receiver.name, description };
+ });
+
+ if (error) {
+ return ;
+ }
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ contactPointsSelector: css({
+ marginTop: theme.spacing(1),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRouting.tsx
new file mode 100644
index 00000000000..5b804f8ec09
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRouting.tsx
@@ -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([], (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();
+ 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 (
+
+
+ {shouldShowAM && (
+
+
+
+ {' '}
+ Alert manager:
+
+ {alertManagerContactPoint.alertManager.name}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ });
+}
+
+function LinkToContactPoints() {
+ const hrefToContactPoints = '/alerting/notifications';
+ return (
+
+
+ To browse contact points and create new ones go to
+ Contact points
+
+
+
+ );
+}
+
+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),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
index 5a2ef4156cd..294dab53cc4 100644
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx
@@ -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(
,
@@ -327,7 +332,7 @@ describe('NotificationPreviewByAlertmanager', () => {
render(
,
@@ -382,7 +387,7 @@ describe('NotificationPreviewByAlertmanager', () => {
render(
,
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx
index 49e54dc76ed..1db96a0ff64 100644
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx
@@ -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 (
@@ -98,7 +97,7 @@ export const NotificationPreview = ({
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
}>
- {alertManagerSourceNamesAndImage.map((alertManagerSource) => (
+ {alertManagerDataSources.map((alertManagerSource) => (
{' '}
Alert manager:
-
+
{alertManagerSource.name}
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx
deleted file mode 100644
index 289da5be638..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useGetAlertManagersSourceNamesAndImage.tsx
+++ /dev/null
@@ -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;
-};
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx
index 510b50c4a3d..938550ae0d8 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx
@@ -18,7 +18,7 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
formState: { errors },
setValue,
watch,
- } = useFormContext();
+ } = useFormContext();
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);
diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx
index b1a00a5386e..719dedfd30e 100644
--- a/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx
+++ b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx
@@ -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) => (
{children}
@@ -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) => (
{children}
@@ -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) => (
@@ -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}` });
diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx
index 57c68ccfe62..05fdc334d94 100644
--- a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx
+++ b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx
@@ -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) => {
diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts
index ca9aa17d0d0..b02cd19940c 100644
--- a/public/app/features/alerting/unified/types/rule-form.ts
+++ b/public/app/features/alerting/unified/types/rule-form.ts
@@ -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;
diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts
index eb40660e36b..de43e658649 100644
--- a/public/app/features/alerting/unified/utils/datasource.ts
+++ b/public/app/features/alerting/unified/utils/datasource.ts
@@ -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 {
diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts
index 7b3d1d26612..26d2c1d7c98 100644
--- a/public/app/features/alerting/unified/utils/rule-form.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.ts
@@ -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');