diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx index 0e41790c7c6..ac672c80ac9 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx @@ -40,6 +40,7 @@ import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import Receivers from './ContactPoints.v1'; + jest.mock('../../api/alertmanager'); jest.mock('../../api/grafana'); jest.mock('../../utils/config'); @@ -540,7 +541,7 @@ describe('Receivers', () => { expect(ui.newContactPointButton.get()).toBeInTheDocument(); }); - describe('Contact points state', () => { + describe('Contact points health', () => { it('Should render error notifications when there are some points state ', async () => { mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); @@ -709,5 +710,30 @@ describe('Receivers', () => { expect(receiverRows[1]).toHaveTextContent('critical'); expect(receiverRows).toHaveLength(2); }); + + it('Should render "Unused" warning if a contact point is not used in route configuration', async () => { + mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); + mocks.api.updateConfig.mockResolvedValue(); + mocks.api.fetchConfig.mockResolvedValue({ + ...someGrafanaAlertManagerConfig, + alertmanager_config: { ...someGrafanaAlertManagerConfig.alertmanager_config, route: { receiver: 'default' } }, + }); + + mocks.hooks.useGetContactPointsState.mockReturnValue(emptyContactPointsState); + renderReceivers(); + + await ui.receiversTable.find(); + //should not render notification error + expect(ui.notificationError.query()).not.toBeInTheDocument(); + //contact points are not expandable + expect(ui.contactPointsCollapseToggle.query()).not.toBeInTheDocument(); + //should render receivers, only one dynamic table + let receiverRows = within(screen.getByTestId('dynamic-table')).getAllByTestId('row'); + + expect(receiverRows).toHaveLength(2); + expect(receiverRows[0]).toHaveTextContent('default'); + expect(receiverRows[1]).toHaveTextContent('critical'); + expect(receiverRows[1]).toHaveTextContent('Unused'); + }); }); }); diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 44120922d81..e4e1ffdf2e4 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -27,6 +27,7 @@ import { ReceiversSection } from './ReceiversSection'; import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge'; import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp'; import { ReceiverWithTypes } from './grafanaAppReceivers/types'; +import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth'; interface UpdateActionProps extends ActionProps { onClickDeleteReceiver: (receiverName: string) => void; @@ -57,6 +58,7 @@ function UpdateActions({ permissions, alertManagerName, receiverName, onClickDel ); } + interface ActionProps { permissions: { read: AccessControlAction; @@ -80,6 +82,7 @@ function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps ); } + interface ReceiverErrorProps { errorCount: number; errorDetail?: string; @@ -146,6 +149,7 @@ const useContactPointsState = (alertManagerName: string) => { const errorStateAvailable = Object.keys(receivers).length > 0; return { contactPointsState, errorStateAvailable }; }; + interface ReceiverItem { name: string; types: string[]; @@ -170,6 +174,7 @@ type NotifierItemTableProps = DynamicTableItemProps; interface NotifiersTableProps { notifiersState: NotifiersState; } + const isLastNotifyNullDate = (lastNotify: string) => lastNotify === '0001-01-01T00:00:00.000Z'; function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) { @@ -234,6 +239,7 @@ function NotifiersTable({ notifiersState }: NotifiersTableProps) { }, ]; } + const notifierRows: NotifierItemTableProps[] = Object.entries(notifiersState).flatMap((typeState) => typeState[1].map((notifierStatus, index) => { return { @@ -263,6 +269,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => { const permissions = getNotificationsPermissions(alertManagerName); const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); + const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config); const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName); // receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted @@ -283,6 +290,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => { } setReceiverToDelete(undefined); }; + const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []); const rows: RowItemTableProps[] = useMemo(() => { return ( @@ -309,6 +317,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => { alertManagerName, errorStateAvailable, contactPointsState, + configHealth, onClickDeleteReceiver, permissions, isVanillaAM @@ -369,8 +378,12 @@ const errorsByReceiver = (contactPointsState: ContactPointsState, receiverName: const someNotifiersWithNoAttempt = (contactPointsState: ContactPointsState, receiverName: string) => { const notifiers = Object.values(contactPointsState?.receivers[receiverName]?.notifiers ?? {}); - const hasSomeWitNoAttempt = - notifiers.length === 0 || notifiers.flat().some((status) => isLastNotifyNullDate(status.lastNotifyAttempt)); + + if (notifiers.length === 0) { + return false; + } + + const hasSomeWitNoAttempt = notifiers.flat().some((status) => isLastNotifyNullDate(status.lastNotifyAttempt)); return hasSomeWitNoAttempt; }; @@ -378,6 +391,7 @@ function useGetColumns( alertManagerName: string, errorStateAvailable: boolean, contactPointsState: ContactPointsState | undefined, + configHealth: AlertmanagerConfigHealth, onClickDeleteReceiver: (receiverName: string) => void, permissions: { read: AccessControlAction; @@ -388,17 +402,22 @@ function useGetColumns( isVanillaAM: boolean ): RowTableColumnProps[] { const tableStyles = useStyles2(getAlertTableStyles); + + const enableHealthColumn = + errorStateAvailable || Object.values(configHealth.contactPoints).some((cp) => cp.matchingRoutes === 0); + const baseColumns: RowTableColumnProps[] = [ { id: 'name', label: 'Contact point name', renderCell: ({ data: { name, provisioned } }) => ( - + <>
{name}
{provisioned && } -
+ ), - size: 1, + size: 3, + className: tableStyles.nameCell, }, { id: 'type', @@ -406,15 +425,20 @@ function useGetColumns( renderCell: ({ data: { types, grafanaAppReceiverType } }) => ( <>{grafanaAppReceiverType ? : types.join(', ')} ), - size: 1, + size: 2, }, ]; const healthColumn: RowTableColumnProps = { id: 'health', label: 'Health', renderCell: ({ data: { name } }) => { + if (configHealth.contactPoints[name]?.matchingRoutes === 0) { + return ; + } + return ( - contactPointsState && ( + contactPointsState && + Object.entries(contactPointsState.receivers).length > 0 && ( + ); +} diff --git a/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts b/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts new file mode 100644 index 00000000000..4327ccae21f --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/useAlertmanagerConfigHealth.ts @@ -0,0 +1,43 @@ +import { countBy } from 'lodash'; + +import { AlertmanagerConfig, Route } from '../../../../../plugins/datasource/alertmanager/types'; + +export interface ContactPointConfigHealth { + matchingRoutes: number; +} + +export interface AlertmanagerConfigHealth { + contactPoints: Record; +} + +export function useAlertmanagerConfigHealth(config: AlertmanagerConfig): AlertmanagerConfigHealth { + if (!config.receivers) { + return { contactPoints: {} }; + } + + if (!config.route) { + return { contactPoints: Object.fromEntries(config.receivers.map((r) => [r.name, { matchingRoutes: 0 }])) }; + } + + const definedContactPointNames = config.receivers?.map((receiver) => receiver.name) ?? []; + const usedContactPoints = getUsedContactPoints(config.route); + const usedContactPointCounts = countBy(usedContactPoints); + + const contactPointsHealth: AlertmanagerConfigHealth['contactPoints'] = {}; + const configHealth: AlertmanagerConfigHealth = { contactPoints: contactPointsHealth }; + + definedContactPointNames.forEach((contactPointName) => { + contactPointsHealth[contactPointName] = { matchingRoutes: usedContactPointCounts[contactPointName] ?? 0 }; + }); + + return configHealth; +} + +function getUsedContactPoints(route: Route): string[] { + const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? []; + if (route.receiver) { + return [route.receiver, ...childrenContactPoints]; + } + + return childrenContactPoints; +} diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 5d48cb6a61e..33d01764c7c 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -18,6 +18,7 @@ import { AlertmanagerStatus, AlertState, GrafanaManagedReceiverConfig, + MatcherOperator, Silence, SilenceState, } from 'app/plugins/datasource/alertmanager/types'; @@ -387,6 +388,12 @@ export const someGrafanaAlertManagerConfig: AlertManagerCortexConfig = { alertmanager_config: { route: { receiver: 'default', + routes: [ + { + receiver: 'critical', + object_matchers: [['severity', MatcherOperator.equal, 'critical']], + }, + ], }, receivers: [ { @@ -628,6 +635,7 @@ export function getGrafanaRule(override?: Partial) { ...override, }); } + export function getCloudRule(override?: Partial) { return mockCombinedRule({ namespace: { diff --git a/public/app/features/alerting/unified/styles/table.ts b/public/app/features/alerting/unified/styles/table.ts index 422dd4de9a6..cbfc63d7245 100644 --- a/public/app/features/alerting/unified/styles/table.ts +++ b/public/app/features/alerting/unified/styles/table.ts @@ -27,6 +27,9 @@ export const getAlertTableStyles = (theme: GrafanaTheme2) => ({ colExpand: css` width: 36px; `, + nameCell: css` + gap: ${theme.spacing(1)}; + `, actionsCell: css` text-align: right; width: 1%;