Alerting: Display a warning when a contact point is not in use (#70506)

This commit is contained in:
Konrad Lalik 2023-06-26 11:05:44 +02:00 committed by GitHub
parent ae5b818c6e
commit 9ebede1e18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 125 additions and 10 deletions

View File

@ -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');
});
});
});

View File

@ -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
</Authorize>
);
}
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<NotifierStatus>;
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 } }) => (
<Stack alignItems="center">
<>
<div>{name}</div>
{provisioned && <ProvisioningBadge />}
</Stack>
</>
),
size: 1,
size: 3,
className: tableStyles.nameCell,
},
{
id: 'type',
@ -406,15 +425,20 @@ function useGetColumns(
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
),
size: 1,
size: 2,
},
];
const healthColumn: RowTableColumnProps = {
id: 'health',
label: 'Health',
renderCell: ({ data: { name } }) => {
if (configHealth.contactPoints[name]?.matchingRoutes === 0) {
return <UnusedContactPointBadge />;
}
return (
contactPointsState && (
contactPointsState &&
Object.entries(contactPointsState.receivers).length > 0 && (
<ReceiverHealth
errorsByReceiver={errorsByReceiver(contactPointsState, name)}
someWithNoAttempt={someNotifiersWithNoAttempt(contactPointsState, name)}
@ -422,12 +446,12 @@ function useGetColumns(
)
);
},
size: 1,
size: '160px',
};
return [
...baseColumns,
...(errorStateAvailable ? [healthColumn] : []),
...(enableHealthColumn ? [healthColumn] : []),
{
id: 'actions',
label: 'Actions',
@ -452,3 +476,14 @@ function useGetColumns(
},
];
}
function UnusedContactPointBadge() {
return (
<Badge
text="Unused"
color="orange"
icon="exclamation-triangle"
tooltip="This contact point is not used in any notification policy and it will not receive any alerts"
/>
);
}

View File

@ -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<string, ContactPointConfigHealth>;
}
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;
}

View File

@ -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<CombinedRule>) {
...override,
});
}
export function getCloudRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {

View File

@ -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%;