mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Display a warning when a contact point is not in use (#70506)
This commit is contained in:
@@ -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 { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
|
|
||||||
import Receivers from './ContactPoints.v1';
|
import Receivers from './ContactPoints.v1';
|
||||||
|
|
||||||
jest.mock('../../api/alertmanager');
|
jest.mock('../../api/alertmanager');
|
||||||
jest.mock('../../api/grafana');
|
jest.mock('../../api/grafana');
|
||||||
jest.mock('../../utils/config');
|
jest.mock('../../utils/config');
|
||||||
@@ -540,7 +541,7 @@ describe('Receivers', () => {
|
|||||||
expect(ui.newContactPointButton.get()).toBeInTheDocument();
|
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 () => {
|
it('Should render error notifications when there are some points state ', async () => {
|
||||||
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
|
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
|
||||||
mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
|
mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
|
||||||
@@ -709,5 +710,30 @@ describe('Receivers', () => {
|
|||||||
expect(receiverRows[1]).toHaveTextContent('critical');
|
expect(receiverRows[1]).toHaveTextContent('critical');
|
||||||
expect(receiverRows).toHaveLength(2);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { ReceiversSection } from './ReceiversSection';
|
|||||||
import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge';
|
import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge';
|
||||||
import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp';
|
import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp';
|
||||||
import { ReceiverWithTypes } from './grafanaAppReceivers/types';
|
import { ReceiverWithTypes } from './grafanaAppReceivers/types';
|
||||||
|
import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth';
|
||||||
|
|
||||||
interface UpdateActionProps extends ActionProps {
|
interface UpdateActionProps extends ActionProps {
|
||||||
onClickDeleteReceiver: (receiverName: string) => void;
|
onClickDeleteReceiver: (receiverName: string) => void;
|
||||||
@@ -57,6 +58,7 @@ function UpdateActions({ permissions, alertManagerName, receiverName, onClickDel
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionProps {
|
interface ActionProps {
|
||||||
permissions: {
|
permissions: {
|
||||||
read: AccessControlAction;
|
read: AccessControlAction;
|
||||||
@@ -80,6 +82,7 @@ function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps
|
|||||||
</Authorize>
|
</Authorize>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiverErrorProps {
|
interface ReceiverErrorProps {
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
errorDetail?: string;
|
errorDetail?: string;
|
||||||
@@ -146,6 +149,7 @@ const useContactPointsState = (alertManagerName: string) => {
|
|||||||
const errorStateAvailable = Object.keys(receivers).length > 0;
|
const errorStateAvailable = Object.keys(receivers).length > 0;
|
||||||
return { contactPointsState, errorStateAvailable };
|
return { contactPointsState, errorStateAvailable };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ReceiverItem {
|
interface ReceiverItem {
|
||||||
name: string;
|
name: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
@@ -170,6 +174,7 @@ type NotifierItemTableProps = DynamicTableItemProps<NotifierStatus>;
|
|||||||
interface NotifiersTableProps {
|
interface NotifiersTableProps {
|
||||||
notifiersState: NotifiersState;
|
notifiersState: NotifiersState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLastNotifyNullDate = (lastNotify: string) => lastNotify === '0001-01-01T00:00:00.000Z';
|
const isLastNotifyNullDate = (lastNotify: string) => lastNotify === '0001-01-01T00:00:00.000Z';
|
||||||
|
|
||||||
function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) {
|
function LastNotify({ lastNotifyDate }: { lastNotifyDate: string }) {
|
||||||
@@ -234,6 +239,7 @@ function NotifiersTable({ notifiersState }: NotifiersTableProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifierRows: NotifierItemTableProps[] = Object.entries(notifiersState).flatMap((typeState) =>
|
const notifierRows: NotifierItemTableProps[] = Object.entries(notifiersState).flatMap((typeState) =>
|
||||||
typeState[1].map((notifierStatus, index) => {
|
typeState[1].map((notifierStatus, index) => {
|
||||||
return {
|
return {
|
||||||
@@ -263,6 +269,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
|
|||||||
const permissions = getNotificationsPermissions(alertManagerName);
|
const permissions = getNotificationsPermissions(alertManagerName);
|
||||||
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
||||||
|
|
||||||
|
const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config);
|
||||||
const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName);
|
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
|
// 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);
|
setReceiverToDelete(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []);
|
const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []);
|
||||||
const rows: RowItemTableProps[] = useMemo(() => {
|
const rows: RowItemTableProps[] = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -309,6 +317,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
|
|||||||
alertManagerName,
|
alertManagerName,
|
||||||
errorStateAvailable,
|
errorStateAvailable,
|
||||||
contactPointsState,
|
contactPointsState,
|
||||||
|
configHealth,
|
||||||
onClickDeleteReceiver,
|
onClickDeleteReceiver,
|
||||||
permissions,
|
permissions,
|
||||||
isVanillaAM
|
isVanillaAM
|
||||||
@@ -369,8 +378,12 @@ const errorsByReceiver = (contactPointsState: ContactPointsState, receiverName:
|
|||||||
|
|
||||||
const someNotifiersWithNoAttempt = (contactPointsState: ContactPointsState, receiverName: string) => {
|
const someNotifiersWithNoAttempt = (contactPointsState: ContactPointsState, receiverName: string) => {
|
||||||
const notifiers = Object.values(contactPointsState?.receivers[receiverName]?.notifiers ?? {});
|
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;
|
return hasSomeWitNoAttempt;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,6 +391,7 @@ function useGetColumns(
|
|||||||
alertManagerName: string,
|
alertManagerName: string,
|
||||||
errorStateAvailable: boolean,
|
errorStateAvailable: boolean,
|
||||||
contactPointsState: ContactPointsState | undefined,
|
contactPointsState: ContactPointsState | undefined,
|
||||||
|
configHealth: AlertmanagerConfigHealth,
|
||||||
onClickDeleteReceiver: (receiverName: string) => void,
|
onClickDeleteReceiver: (receiverName: string) => void,
|
||||||
permissions: {
|
permissions: {
|
||||||
read: AccessControlAction;
|
read: AccessControlAction;
|
||||||
@@ -388,17 +402,22 @@ function useGetColumns(
|
|||||||
isVanillaAM: boolean
|
isVanillaAM: boolean
|
||||||
): RowTableColumnProps[] {
|
): RowTableColumnProps[] {
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
|
|
||||||
|
const enableHealthColumn =
|
||||||
|
errorStateAvailable || Object.values(configHealth.contactPoints).some((cp) => cp.matchingRoutes === 0);
|
||||||
|
|
||||||
const baseColumns: RowTableColumnProps[] = [
|
const baseColumns: RowTableColumnProps[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
label: 'Contact point name',
|
label: 'Contact point name',
|
||||||
renderCell: ({ data: { name, provisioned } }) => (
|
renderCell: ({ data: { name, provisioned } }) => (
|
||||||
<Stack alignItems="center">
|
<>
|
||||||
<div>{name}</div>
|
<div>{name}</div>
|
||||||
{provisioned && <ProvisioningBadge />}
|
{provisioned && <ProvisioningBadge />}
|
||||||
</Stack>
|
</>
|
||||||
),
|
),
|
||||||
size: 1,
|
size: 3,
|
||||||
|
className: tableStyles.nameCell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'type',
|
id: 'type',
|
||||||
@@ -406,15 +425,20 @@ function useGetColumns(
|
|||||||
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
|
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
|
||||||
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
|
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
|
||||||
),
|
),
|
||||||
size: 1,
|
size: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const healthColumn: RowTableColumnProps = {
|
const healthColumn: RowTableColumnProps = {
|
||||||
id: 'health',
|
id: 'health',
|
||||||
label: 'Health',
|
label: 'Health',
|
||||||
renderCell: ({ data: { name } }) => {
|
renderCell: ({ data: { name } }) => {
|
||||||
|
if (configHealth.contactPoints[name]?.matchingRoutes === 0) {
|
||||||
|
return <UnusedContactPointBadge />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
contactPointsState && (
|
contactPointsState &&
|
||||||
|
Object.entries(contactPointsState.receivers).length > 0 && (
|
||||||
<ReceiverHealth
|
<ReceiverHealth
|
||||||
errorsByReceiver={errorsByReceiver(contactPointsState, name)}
|
errorsByReceiver={errorsByReceiver(contactPointsState, name)}
|
||||||
someWithNoAttempt={someNotifiersWithNoAttempt(contactPointsState, name)}
|
someWithNoAttempt={someNotifiersWithNoAttempt(contactPointsState, name)}
|
||||||
@@ -422,12 +446,12 @@ function useGetColumns(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 1,
|
size: '160px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...baseColumns,
|
...baseColumns,
|
||||||
...(errorStateAvailable ? [healthColumn] : []),
|
...(enableHealthColumn ? [healthColumn] : []),
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
label: '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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
AlertmanagerStatus,
|
AlertmanagerStatus,
|
||||||
AlertState,
|
AlertState,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
|
MatcherOperator,
|
||||||
Silence,
|
Silence,
|
||||||
SilenceState,
|
SilenceState,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
@@ -387,6 +388,12 @@ export const someGrafanaAlertManagerConfig: AlertManagerCortexConfig = {
|
|||||||
alertmanager_config: {
|
alertmanager_config: {
|
||||||
route: {
|
route: {
|
||||||
receiver: 'default',
|
receiver: 'default',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
receiver: 'critical',
|
||||||
|
object_matchers: [['severity', MatcherOperator.equal, 'critical']],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
receivers: [
|
receivers: [
|
||||||
{
|
{
|
||||||
@@ -628,6 +635,7 @@ export function getGrafanaRule(override?: Partial<CombinedRule>) {
|
|||||||
...override,
|
...override,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCloudRule(override?: Partial<CombinedRule>) {
|
export function getCloudRule(override?: Partial<CombinedRule>) {
|
||||||
return mockCombinedRule({
|
return mockCombinedRule({
|
||||||
namespace: {
|
namespace: {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const getAlertTableStyles = (theme: GrafanaTheme2) => ({
|
|||||||
colExpand: css`
|
colExpand: css`
|
||||||
width: 36px;
|
width: 36px;
|
||||||
`,
|
`,
|
||||||
|
nameCell: css`
|
||||||
|
gap: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
actionsCell: css`
|
actionsCell: css`
|
||||||
text-align: right;
|
text-align: right;
|
||||||
width: 1%;
|
width: 1%;
|
||||||
|
|||||||
Reference in New Issue
Block a user