Alerting: Add simplified routing metadata to the details tab (#106403)
This commit is contained in:
@@ -33,6 +33,10 @@
|
||||
"./unstable": {
|
||||
"import": "./src/unstable.ts",
|
||||
"require": "./src/unstable.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./src/testing.ts",
|
||||
"require": "./src/testing.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
+32
@@ -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,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user