Alerting: Add simplified routing metadata to the details tab (#106403)

This commit is contained in:
Gilles De Mey
2025-06-13 13:49:52 +02:00
committed by GitHub
parent 6a11d462cb
commit 4c2bfe8263
13 changed files with 294 additions and 8 deletions
+4
View File
@@ -33,6 +33,10 @@
"./unstable": {
"import": "./src/unstable.ts",
"require": "./src/unstable.ts"
},
"./testing": {
"import": "./src/testing.ts",
"require": "./src/testing.ts"
}
},
"scripts": {
+6
View File
@@ -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';
+2
View File
@@ -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,
@@ -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)];
@@ -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(<ContactPointLink name={RECEIVER_NAME} />);
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(<ContactPointLink name={notFound} />);
// 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();
});
});
@@ -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<ComponentProps<typeof TextLink>, '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 (
<TextLink href={makeEditContactPointLink(receiverUID, { alertmanager: 'grafana' })} inline={false} {...props}>
{name}
</TextLink>
);
}
return name;
};
const loader = <Skeleton height={8} width={64} />;
@@ -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: <ContactPointLink name={contactPointName} variant="bodySmall" />,
});
}
}
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',
}),
@@ -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(<Details rule={rule} />);
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(<Details rule={rule} />);
// 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();
});
});
@@ -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) => {
)}
</DetailGroup>
{/* show simplified routing information for Grafana managed alert rules */}
{rulerRuleType.grafana.alertingRule(rule.rulerRule) &&
!isEmpty(rule.rulerRule.grafana_alert.notification_settings) && (
<NotificationSettings rulerRule={rule.rulerRule} />
)}
{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 <Text color="primary">{tokenizeValue}</Text>;
}
interface NotificationSettingsProps {
rulerRule: RulerGrafanaRuleDTO<GrafanaAlertingRuleDefinition>;
}
const NotificationSettings = ({ rulerRule }: NotificationSettingsProps) => {
const notificationSettings = rulerRule.grafana_alert.notification_settings;
if (!notificationSettings) {
return null;
}
return (
<DetailGroup title={t('alerting.alert.notification-configuration.group-title', 'Notification configuration')}>
<DetailText
id="receiver"
label={t('alerting.alert.notification-configuration.contact-point', 'Contact point')}
value={<ContactPointLink name={notificationSettings.receiver} />}
/>
{notificationSettings.mute_time_intervals && (
<DetailText
id="mute-timings"
label={t('alerting.alert.notification-configuration.mute-timings', 'Mute timings')}
value={
<>
{notificationSettings.mute_time_intervals.map((intervalName, index) => (
<Fragment key={intervalName}>
<TextLink href={makeEditTimeIntervalLink(intervalName, { alertmanager: 'grafana' })}>
{intervalName}
</TextLink>
{index < notificationSettings.mute_time_intervals!.length - 1 && ', '}
</Fragment>
))}
</>
}
/>
)}
{notificationSettings.active_time_intervals && (
<DetailText
id="active-time-intervals"
label={t('alerting.alert.notification-configuration.active-timings', 'Active time intervals')}
value={
<>
{notificationSettings.active_time_intervals.map((intervalName, index) => (
<Fragment key={intervalName}>
<TextLink href={makeEditTimeIntervalLink(intervalName, { alertmanager: 'grafana' })}>
{intervalName}
</TextLink>
{index < notificationSettings.active_time_intervals!.length - 1 && ', '}
</Fragment>
))}
</>
}
/>
)}
{/* override grouping */}
{notificationSettings.group_by && (
<DetailText
id="group-by"
label={t('alerting.alert.notification-configuration.group-by', 'Grouped by')}
value={notificationSettings.group_by.join(', ')}
/>
)}
{/* override timings */}
{(notificationSettings.group_interval ||
notificationSettings.group_wait ||
notificationSettings.repeat_interval) && (
<DetailText
id="timing-options"
label={t('alerting.alert.notification-configuration.timing-options', 'Timings')}
value={
<TimingOptionsMeta
timingOptions={{
group_interval: notificationSettings.group_interval,
group_wait: notificationSettings.group_wait,
repeat_interval: notificationSettings.repeat_interval,
}}
/>
}
/>
)}
</DetailGroup>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
metadata: css({
display: 'grid',
@@ -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<string, string>) {
return createRelativeUrl(`/alerting/notifications/receivers/${encodeURIComponent(name)}/edit`, options);
}
export function makeEditTimeIntervalLink(name: string, options?: Record<string, string>) {
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<T, E = Error>(
fn: () => Promise<T>,
+2
View File
@@ -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<StoreState>) {
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
listenerMiddleware.middleware,
alertingApi.middleware,
alertingAPIv0alpha1.middleware,
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
cloudMigrationAPI.middleware,
+9
View File
@@ -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",