From 4c2bfe82639222c57f6964cf5bed719d4d61f2c8 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 13 Jun 2025 13:49:52 +0200 Subject: [PATCH] Alerting: Add simplified routing metadata to the details tab (#106403) --- packages/grafana-alerting/package.json | 4 + ... => ContactPointSelector.test.scenario.ts} | 0 packages/grafana-alerting/src/testing.ts | 6 ++ public/app/core/reducers/root.ts | 2 + .../ContactPointLink.test.scenario.ts | 32 ++++++ .../rule-viewer/ContactPointLink.test.tsx | 33 ++++++ .../rule-viewer/ContactPointLink.tsx | 37 +++++++ .../components/rule-viewer/RuleViewer.tsx | 21 ++-- .../rule-viewer/tabs/Details.test.tsx | 45 ++++++++ .../components/rule-viewer/tabs/Details.tsx | 100 +++++++++++++++++- .../features/alerting/unified/utils/misc.ts | 11 ++ public/app/store/configureStore.ts | 2 + public/locales/en-US/grafana.json | 9 ++ 13 files changed, 294 insertions(+), 8 deletions(-) rename packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/{ContactPointSelector.test.scenario.tsx => ContactPointSelector.test.scenario.ts} (100%) create mode 100644 packages/grafana-alerting/src/testing.ts create mode 100644 public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.scenario.ts create mode 100644 public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx 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",