diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json
index 31980da5f3a..1d88fe53f76 100644
--- a/packages/grafana-alerting/package.json
+++ b/packages/grafana-alerting/package.json
@@ -33,6 +33,10 @@
"./unstable": {
"import": "./src/unstable.ts",
"require": "./src/unstable.ts"
+ },
+ "./testing": {
+ "import": "./src/testing.ts",
+ "require": "./src/testing.ts"
}
},
"scripts": {
diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts
similarity index 100%
rename from packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx
rename to packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts
diff --git a/packages/grafana-alerting/src/testing.ts b/packages/grafana-alerting/src/testing.ts
new file mode 100644
index 00000000000..3de18f49993
--- /dev/null
+++ b/packages/grafana-alerting/src/testing.ts
@@ -0,0 +1,6 @@
+// export MSW handlers for testing
+export * from './grafana/api/v0alpha1/mocks/handlers';
+
+// export mocks and factories
+export * from './grafana/api/v0alpha1/mocks/fakes/common';
+export * from './grafana/api/v0alpha1/mocks/fakes/Receivers';
diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts
index 0ed1d930384..d28d2f9caba 100644
--- a/public/app/core/reducers/root.ts
+++ b/public/app/core/reducers/root.ts
@@ -1,6 +1,7 @@
import { ReducersMapObject } from '@reduxjs/toolkit';
import { AnyAction, combineReducers } from 'redux';
+import { alertingAPIv0alpha1 } from '@grafana/alerting/unstable';
import sharedReducers from 'app/core/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
@@ -60,6 +61,7 @@ const rootReducers = {
...authConfigReducers,
plugins: pluginsReducer,
[alertingApi.reducerPath]: alertingApi.reducer,
+ [alertingAPIv0alpha1.reducerPath]: alertingAPIv0alpha1.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
[cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer,
diff --git a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.scenario.ts b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.scenario.ts
new file mode 100644
index 00000000000..5d191fbb11a
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.scenario.ts
@@ -0,0 +1,32 @@
+import {
+ ContactPointFactory,
+ EmailIntegrationFactory,
+ ListReceiverApiResponseFactory,
+ SlackIntegrationFactory,
+ listReceiverHandler,
+} from '@grafana/alerting/testing';
+
+export const RECEIVER_NAME = 'my-receiver';
+export const RECEIVER_UID = 'my-receiver';
+
+// single response scenario
+export const listContactPointsResponse = ListReceiverApiResponseFactory.build({
+ items: [
+ ContactPointFactory.build({
+ metadata: {
+ name: RECEIVER_UID,
+ },
+ spec: {
+ title: RECEIVER_NAME,
+ integrations: [EmailIntegrationFactory.build(), SlackIntegrationFactory.build()],
+ },
+ }),
+ ],
+});
+export const listContactPointsScenario = [listReceiverHandler(listContactPointsResponse)];
+
+// empty response scenario
+export const listContactPointEmptyResponse = ListReceiverApiResponseFactory.build({
+ items: [],
+});
+export const listContactPointsEmptyResponseScenario = [listReceiverHandler(listContactPointEmptyResponse)];
diff --git a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx
new file mode 100644
index 00000000000..634a3e6b809
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from 'test/test-utils';
+
+import { setupMockServer } from '@grafana/test-utils/server';
+
+import { ContactPointLink } from './ContactPointLink';
+import {
+ RECEIVER_NAME,
+ listContactPointsEmptyResponseScenario,
+ listContactPointsScenario,
+} from './ContactPointLink.test.scenario';
+
+const server = setupMockServer();
+
+describe('render contact point link', () => {
+ it('should render correctly', async () => {
+ server.use(...listContactPointsScenario);
+
+ render();
+ expect(await screen.findByRole('link', { name: RECEIVER_NAME })).toBeInTheDocument();
+ });
+
+ it('should render nothing if it fails to find the receiver', async () => {
+ server.use(...listContactPointsEmptyResponseScenario);
+
+ const notFound = 'not-found';
+ render();
+
+ // it should be rendered as plain text
+ expect(await screen.findByText(notFound)).toBeInTheDocument();
+ // but not as link
+ expect(screen.queryByRole('link', { name: notFound })).not.toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx
new file mode 100644
index 00000000000..14b0a6b87cb
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx
@@ -0,0 +1,37 @@
+import { ComponentProps } from 'react';
+import Skeleton from 'react-loading-skeleton';
+
+import { alertingAPIv0alpha1 } from '@grafana/alerting/unstable';
+import { TextLink } from '@grafana/ui';
+
+import { makeEditContactPointLink } from '../../utils/misc';
+
+interface ContactPointLinkProps extends Omit, 'href' | 'children'> {
+ name: string;
+}
+
+export const ContactPointLink = ({ name, ...props }: ContactPointLinkProps) => {
+ // find receiver by name – since this is what we store in the alert rule definition
+ const { currentData, isLoading, isSuccess } = alertingAPIv0alpha1.endpoints.listReceiver.useQuery({
+ fieldSelector: `spec.title=${name}`,
+ });
+
+ // grab the first result from the fieldSelector result
+ const receiverUID = currentData?.items.at(0)?.metadata.name;
+
+ if (isLoading) {
+ return loader;
+ }
+
+ if (isSuccess && receiverUID) {
+ return (
+
+ {name}
+
+ );
+ }
+
+ return name;
+};
+
+const loader = ;
diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
index 85db1d13e0e..e5d5463ebf2 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
@@ -59,6 +59,7 @@ import { WithReturnButton } from '../WithReturnButton';
import { decodeGrafanaNamespace } from '../expressions/util';
import { RedirectToCloneRule } from '../rules/CloneRule';
+import { ContactPointLink } from './ContactPointLink';
import { FederatedRuleWarning } from './FederatedRuleWarning';
import PausedBadge from './PausedBadge';
import { useAlertRule } from './RuleContext';
@@ -180,7 +181,7 @@ const RuleViewer = () => {
};
const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
- const { labels, annotations, group } = rule;
+ const { labels, annotations, group, rulerRule } = rule;
const metadata: PageInfoItem[] = [];
const runbookUrl = annotations[Annotation.runbookURL];
@@ -194,6 +195,18 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
const interval = group.interval;
const styles = useStyles2(getStyles);
+ // if the alert rule uses simplified routing, we'll show a link to the contact point
+ if (rulerRuleType.grafana.alertingRule(rulerRule)) {
+ const contactPointName = rulerRule.grafana_alert.notification_settings?.receiver;
+
+ if (contactPointName) {
+ metadata.push({
+ label: t('alerting.create-metadata.label.contact-point', 'Notifications are delivered to'),
+ value: ,
+ });
+ }
+ }
+
if (runbookUrl) {
/* TODO instead of truncating the string, we should use flex and text overflow properly to allow it to take up all of the horizontal space available */
const truncatedUrl = truncate(runbookUrl, { length: 42 });
@@ -490,12 +503,6 @@ export const calculateTotalInstances = (stats: AlertInstanceTotals) => {
};
const getStyles = () => ({
- title: css({
- display: 'flex',
- alignItems: 'center',
- gap: 8,
- minWidth: 0,
- }),
url: css({
wordBreak: 'break-all',
}),
diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx
index ba354f056fd..c445eaa027b 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx
@@ -1,16 +1,26 @@
import { render, screen } from 'test/test-utils';
+import { setupMockServer } from '@grafana/test-utils/server';
+
import { mockCombinedRule } from '../../../mocks';
import { alertingFactory } from '../../../mocks/server/db';
import { setupDataSources } from '../../../testSetup/datasources';
+import { RECEIVER_NAME, listContactPointsScenario } from '../ContactPointLink.test.scenario';
import { Details } from './Details';
+const server = setupMockServer();
+
beforeAll(() => {
setupDataSources();
});
describe('render details tab', () => {
+ beforeEach(() => {
+ // we'll re-use the scenario from the contact point link component
+ server.use(...listContactPointsScenario);
+ });
+
it('should show paused rule', () => {
const rule = mockCombinedRule({
rulerRule: alertingFactory.ruler.grafana.recordingRule.build({
@@ -23,4 +33,39 @@ describe('render details tab', () => {
render( );
expect(screen.getByText(/Alert evaluation currently paused/i)).toBeInTheDocument();
});
+
+ it('should render simplified routing information', async () => {
+ const rule = mockCombinedRule({
+ rulerRule: alertingFactory.ruler.grafana.alertingRule.build({
+ grafana_alert: {
+ notification_settings: {
+ receiver: RECEIVER_NAME,
+ active_time_intervals: ['ati1', 'ati2'],
+ group_by: ['g1', 'g2'],
+ group_interval: '6m',
+ group_wait: '15m',
+ repeat_interval: '6h',
+ mute_time_intervals: ['mti1', 'mti2'],
+ },
+ },
+ }),
+ });
+
+ render( );
+
+ // wait for the reciever link to be loaded
+ expect(await screen.findByRole('link', { name: RECEIVER_NAME })).toBeInTheDocument();
+
+ expect(screen.getByRole('link', { name: 'ati1' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'ati2' })).toBeInTheDocument();
+
+ expect(screen.getByText(/g1, g2/i)).toBeInTheDocument();
+
+ expect(screen.getByRole('link', { name: 'mti1' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'mti2' })).toBeInTheDocument();
+
+ expect(screen.getByText(/6m/i)).toBeInTheDocument();
+ expect(screen.getByText(/15m/i)).toBeInTheDocument();
+ expect(screen.getByText(/6h/i)).toBeInTheDocument();
+ });
});
diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx
index bf807f01bd3..994e2740cf2 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx
@@ -1,18 +1,23 @@
import { css } from '@emotion/css';
import { formatDistanceToNowStrict } from 'date-fns';
-import { isUndefined } from 'lodash';
+import { isEmpty, isUndefined } from 'lodash';
+import { Fragment } from 'react/jsx-runtime';
import { GrafanaTheme2, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Icon, Link, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { useDatasource } from 'app/features/datasources/hooks';
import { CombinedRule } from 'app/types/unified-alerting';
+import { GrafanaAlertingRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { usePendingPeriod } from '../../../hooks/rules/usePendingPeriod';
+import { makeEditTimeIntervalLink } from '../../../utils/misc';
import { getAnnotations, isPausedRule, prometheusRuleType, rulerRuleType } from '../../../utils/rules';
import { isNullDate } from '../../../utils/time';
import { Tokenize } from '../../Tokenize';
import { DetailText } from '../../common/DetailText';
+import { TimingOptionsMeta } from '../../notification-policies/Policy';
+import { ContactPointLink } from '../ContactPointLink';
import { UpdatedByUser } from './version-history/UpdatedBy';
@@ -188,6 +193,12 @@ export const Details = ({ rule }: DetailsProps) => {
)}
+ {/* show simplified routing information for Grafana managed alert rules */}
+ {rulerRuleType.grafana.alertingRule(rule.rulerRule) &&
+ !isEmpty(rule.rulerRule.grafana_alert.notification_settings) && (
+
+ )}
+
{rulerRuleType.grafana.rule(rule.rulerRule) &&
// grafana recording rules don't have these fields
rule.rulerRule.grafana_alert.no_data_state &&
@@ -249,6 +260,93 @@ export function AnnotationValue({ value }: AnnotationValueProps) {
return {tokenizeValue};
}
+interface NotificationSettingsProps {
+ rulerRule: RulerGrafanaRuleDTO;
+}
+
+const NotificationSettings = ({ rulerRule }: NotificationSettingsProps) => {
+ const notificationSettings = rulerRule.grafana_alert.notification_settings;
+ if (!notificationSettings) {
+ return null;
+ }
+
+ return (
+
+ }
+ />
+
+ {notificationSettings.mute_time_intervals && (
+
+ {notificationSettings.mute_time_intervals.map((intervalName, index) => (
+
+
+ {intervalName}
+
+ {index < notificationSettings.mute_time_intervals!.length - 1 && ', '}
+
+ ))}
+ >
+ }
+ />
+ )}
+
+ {notificationSettings.active_time_intervals && (
+
+ {notificationSettings.active_time_intervals.map((intervalName, index) => (
+
+
+ {intervalName}
+
+ {index < notificationSettings.active_time_intervals!.length - 1 && ', '}
+
+ ))}
+ >
+ }
+ />
+ )}
+
+ {/* override grouping */}
+ {notificationSettings.group_by && (
+
+ )}
+
+ {/* override timings */}
+ {(notificationSettings.group_interval ||
+ notificationSettings.group_wait ||
+ notificationSettings.repeat_interval) && (
+
+ }
+ />
+ )}
+
+ );
+};
+
const getStyles = (theme: GrafanaTheme2) => ({
metadata: css({
display: 'grid',
diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts
index d63b2f42497..7b4d5229ba0 100644
--- a/public/app/features/alerting/unified/utils/misc.ts
+++ b/public/app/features/alerting/unified/utils/misc.ts
@@ -193,6 +193,17 @@ export function makePanelLink(dashboardUID: string, panelId: string): string {
return createRelativeUrl(`/d/${encodeURIComponent(dashboardUID)}`, panelParams);
}
+export function makeEditContactPointLink(name: string, options?: Record) {
+ return createRelativeUrl(`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`, options);
+}
+
+export function makeEditTimeIntervalLink(name: string, options?: Record) {
+ return createRelativeUrl('/alerting/routes/mute-timing/edit', {
+ ...options,
+ muteName: name,
+ });
+}
+
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
export function retryWhile(
fn: () => Promise,
diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts
index 8cf9ecde47d..c00680f5aae 100644
--- a/public/app/store/configureStore.ts
+++ b/public/app/store/configureStore.ts
@@ -2,6 +2,7 @@ import { configureStore as reduxConfigureStore, createListenerMiddleware } from
import { setupListeners } from '@reduxjs/toolkit/query';
import { Middleware } from 'redux';
+import { alertingAPIv0alpha1 } from '@grafana/alerting/unstable';
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { cloudMigrationAPI } from 'app/features/migrate-to-cloud/api';
@@ -42,6 +43,7 @@ export function configureStore(initialState?: Partial) {
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
listenerMiddleware.middleware,
alertingApi.middleware,
+ alertingAPIv0alpha1.middleware,
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
cloudMigrationAPI.middleware,
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index d1d7dd616c4..87df7fb5c54 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -363,6 +363,14 @@
"last-updated-by": "Last updated by",
"missing-series-resolve": "Missing series evaluations to resolve",
"no-annotations": "No annotations",
+ "notification-configuration": {
+ "active-timings": "Active time intervals",
+ "contact-point": "Contact point",
+ "group-by": "Grouped by",
+ "group-title": "Notification configuration",
+ "mute-timings": "Mute timings",
+ "timing-options": "Timings"
+ },
"pending-period": "Pending period",
"rule": "Rule",
"rule-identifier": "Rule identifier",
@@ -910,6 +918,7 @@
"copy-to-clipboard": "Copy \"{{label}}\" to clipboard",
"create-metadata": {
"label": {
+ "contact-point": "Notifications are delivered to",
"dashboard": "Dashboard",
"dashboard-and-panel": "Dashboard and panel",
"evaluation-interval": "Evaluation interval",