Alerting: Improve test notification visualization (#113228)

This commit is contained in:
Gilles De Mey
2025-10-31 14:08:24 +01:00
committed by GitHub
parent f9c0924f0c
commit 8010784322
8 changed files with 121 additions and 84 deletions
@@ -1,8 +1,10 @@
/** @deprecated To be deleted - use alertingApiServer API instead */
import { ContactPointsState } from 'app/features/alerting/unified/types/alerting';
import { Receiver, TestReceiversAlert, TestReceiversResult } from 'app/plugins/datasource/alertmanager/types';
import { CONTACT_POINTS_STATE_INTERVAL_MS } from '../utils/constants';
import { getDatasourceAPIUid } from '../utils/datasource';
import { alertingApi } from './alertingApi';
import { fetchContactPointsState } from './grafana';
@@ -19,9 +21,34 @@ export const receiversApi = alertingApi.injectEndpoints({
}
},
}),
testIntegration: build.mutation<TestReceiversResult, TestReceiversOptions>({
query: ({ alertManagerSourceName, receivers, alert }) => ({
method: 'POST',
data: {
receivers,
alert,
},
url: `/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/config/api/v1/receivers/test`,
showErrorAlert: false,
showSuccessAlert: false,
}),
transformResponse: (response: TestReceiversResult) => {
// Check if the response contains errors even though the HTTP status was 200
if (receiversResponseContainsErrors(response)) {
throw new Error(getReceiverResultError(response));
}
return response;
},
}),
}),
});
interface TestReceiversOptions {
alertManagerSourceName: string;
receivers: Receiver[];
alert?: TestReceiversAlert;
}
export const useGetContactPointsState = (alertManagerSourceName: string) => {
const contactPointsStateEmpty: ContactPointsState = { receivers: {}, errorCount: 0 };
const { currentData: contactPointsState } = receiversApi.useContactPointsStateQuery(
@@ -33,3 +60,22 @@ export const useGetContactPointsState = (alertManagerSourceName: string) => {
);
return contactPointsState ?? contactPointsStateEmpty;
};
export const { useTestIntegrationMutation } = receiversApi;
// Helper functions for checking receiver test results
function receiversResponseContainsErrors(result: TestReceiversResult): boolean {
return result.receivers.some((receiver) =>
receiver.grafana_managed_receiver_configs.some((config) => config.status === 'failed')
);
}
function getReceiverResultError(receiversResult: TestReceiversResult): string {
return receiversResult.receivers
.flatMap((receiver) =>
receiver.grafana_managed_receiver_configs
.filter((config) => config.status === 'failed')
.map((config) => config.error ?? 'Unknown error.')
)
.join('; ');
}
@@ -9,7 +9,6 @@ import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import {
ChannelValues,
CloudChannelValues,
@@ -66,7 +65,6 @@ export function ChannelSubForm<R extends ChannelValues>({
const selectedType = watch(typeFieldPath) ?? defaultValues.type;
const parse_mode = watch(`${settingsFieldPath}.parse_mode`);
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
// TODO I don't like integration specific code here but other ways require a bigger refactoring
const onCallIntegrationType = watch(`${settingsFieldPath}.integration_type`);
@@ -232,14 +230,7 @@ export function ChannelSubForm<R extends ChannelValues>({
</div>
<div className={styles.buttons}>
{isTestable && onTest && isTestAvailable && (
<Button
disabled={testingReceiver}
size="xs"
variant="secondary"
type="button"
onClick={() => handleTest()}
icon={testingReceiver ? 'spinner' : 'message'}
>
<Button size="xs" variant="secondary" type="button" onClick={() => handleTest()} icon="message">
<Trans i18nKey="alerting.channel-sub-form.test">Test</Trans>
</Button>
)}
@@ -13,12 +13,10 @@ import { canEditEntity } from 'app/features/alerting/unified/utils/k8s/utils';
import {
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
TestReceiversAlert,
Receiver,
} from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types/store';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { testReceiversAction } from '../../../state/actions';
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import {
formChannelValuesToGrafanaChannelConfig,
@@ -52,7 +50,6 @@ interface Props {
const { useGrafanaNotifiersQuery } = alertmanagerApi;
export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }: Props) => {
const dispatch = useDispatch();
const [createContactPoint] = useCreateContactPoint({
alertmanager: GRAFANA_RULES_SOURCE_NAME,
});
@@ -71,7 +68,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery();
const [testChannelValues, setTestChannelValues] = useState<GrafanaChannelValues>();
const [testReceivers, setTestReceivers] = useState<Receiver[]>();
// transform receiver DTO to form values
const [existingValue, id2original] = useMemo((): [
@@ -112,27 +109,17 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
};
const onTestChannel = (values: GrafanaChannelValues) => {
setTestChannelValues(values);
};
const existing: GrafanaManagedReceiverConfig | undefined = id2original[values.__id];
const chan = formChannelValuesToGrafanaChannelConfig(values, defaultChannelValues, 'test', existing);
const testNotification = (alert?: TestReceiversAlert) => {
if (testChannelValues) {
const existing: GrafanaManagedReceiverConfig | undefined = id2original[testChannelValues.__id];
const chan = formChannelValuesToGrafanaChannelConfig(testChannelValues, defaultChannelValues, 'test', existing);
const receivers: Receiver[] = [
{
name: 'test',
grafana_managed_receiver_configs: [chan],
},
];
const payload = {
alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME,
receivers: [
{
name: 'test',
grafana_managed_receiver_configs: [chan],
},
],
alert,
};
dispatch(testReceiversAction(payload));
}
setTestReceivers(receivers);
};
// If there is no contact point it means we're creating a new one, so scoped permissions doesn't exist yet
@@ -192,11 +179,14 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
editMode && contactPoint && showManageContactPointPermissions(GRAFANA_RULES_SOURCE_NAME, contactPoint)
}
/>
<TestContactPointModal
onDismiss={() => setTestChannelValues(undefined)}
isOpen={!!testChannelValues}
onTest={(alert) => testNotification(alert)}
/>
{testReceivers && (
<TestContactPointModal
onDismiss={() => setTestReceivers(undefined)}
isOpen={!!testReceivers}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
receivers={testReceivers}
/>
)}
</>
);
};
@@ -4,18 +4,21 @@ import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Label, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
import { Alert, Button, Label, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Receiver, TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
import { useTestIntegrationMutation } from '../../../api/receiversApi';
import { defaultAnnotations } from '../../../utils/constants';
import { stringifyErrorLike } from '../../../utils/misc';
import AnnotationsStep from '../../rule-editor/AnnotationsStep';
import LabelsField from '../../rule-editor/labels/LabelsField';
interface Props {
isOpen: boolean;
onDismiss: () => void;
onTest: (alert?: TestReceiversAlert) => void;
alertManagerSourceName: string;
receivers: Receiver[];
}
type AnnoField = {
@@ -40,14 +43,17 @@ const defaultValues: FormFields = {
labels: [{ key: '', value: '' }],
};
export const TestContactPointModal = ({ isOpen, onDismiss, onTest }: Props) => {
export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceName, receivers }: Props) => {
const [notificationType, setNotificationType] = useState<NotificationType>(NotificationType.predefined);
const styles = useStyles2(getStyles);
const formMethods = useForm<FormFields>({ defaultValues, mode: 'onBlur' });
const [testIntegration, { isLoading, error, isSuccess }] = useTestIntegrationMutation();
const onSubmit = async (data: FormFields) => {
let alert: TestReceiversAlert | undefined;
const onSubmit = (data: FormFields) => {
if (notificationType === NotificationType.custom) {
const alert = {
alert = {
annotations: data.annotations
.filter(({ key, value }) => !!key && !!value)
.reduce<Annotations>((acc, { key, value }) => {
@@ -59,10 +65,13 @@ export const TestContactPointModal = ({ isOpen, onDismiss, onTest }: Props) => {
return { ...acc, [key]: value };
}, {}),
};
onTest(alert);
} else {
onTest();
}
await testIntegration({
alertManagerSourceName,
receivers,
alert,
}).unwrap();
};
return (
@@ -71,6 +80,19 @@ export const TestContactPointModal = ({ isOpen, onDismiss, onTest }: Props) => {
isOpen={isOpen}
title={t('alerting.test-contact-point-modal.title-test-contact-point', 'Test contact point')}
>
{Boolean(error) && (
<Alert title={t('alerting.test-contact-point-modal.test-failed', 'Test notification failed')} severity="error">
{stringifyErrorLike(error)}
</Alert>
)}
{isSuccess && (
<Alert
title={t('alerting.test-contact-point-modal.test-successful', 'Test notification sent successfully')}
severity="success"
/>
)}
<div className={styles.section}>
<Label>
<Trans i18nKey="alerting.test-contact-point-modal.notification-message">Notification message</Trans>
@@ -110,7 +132,7 @@ export const TestContactPointModal = ({ isOpen, onDismiss, onTest }: Props) => {
)}
<Modal.ButtonRow>
<Button type="submit">
<Button type="submit" disabled={isLoading}>
<Trans i18nKey="alerting.test-contact-point-modal.send-test-notification">Send test notification</Trans>
</Button>
</Modal.ButtonRow>
@@ -11,7 +11,7 @@ import {
} from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
import { MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER } from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertManagerCortexConfig, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { AlertManagerCortexConfig, AlertState, TestReceiversPayload } from 'app/plugins/datasource/alertmanager/types';
export const grafanaAlertingConfigurationStatusHandler = (
response = defaultGrafanaAlertingConfigurationStatusResponse
@@ -160,9 +160,24 @@ const getReceiversHandler = () =>
});
const testReceiversHandler = () =>
http.post('/api/alertmanager/grafana/config/api/v1/receivers/test', () => {
// TODO: scaffold out response as needed by tests
return HttpResponse.json({});
http.post('/api/alertmanager/grafana/config/api/v1/receivers/test', async ({ request }) => {
const body: TestReceiversPayload = await request.clone().json();
const { receivers = [] } = body;
// Build response with successful test results for each receiver
const testResults = receivers.map((receiver) => ({
name: receiver.name,
grafana_managed_receiver_configs: (receiver.grafana_managed_receiver_configs || []).map((config) => ({
name: config.name || config.type,
uid: config.uid,
status: 'ok' as const,
})),
}));
return HttpResponse.json({
notified_at: new Date().toISOString(),
receivers: testResults,
});
});
const getGroupsHandler = () =>
@@ -2,24 +2,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { isEmpty } from 'lodash';
import { locationService, logMeasurement } from '@grafana/runtime';
import {
AlertManagerCortexConfig,
AlertmanagerGroup,
Matcher,
Receiver,
TestReceiversAlert,
} from 'app/plugins/datasource/alertmanager/types';
import { AlertManagerCortexConfig, AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { ThunkResult } from 'app/types/store';
import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting';
import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
import {
deleteAlertManagerConfig,
fetchAlertGroups,
testReceivers,
updateAlertManagerConfig,
} from '../api/alertmanager';
import { deleteAlertManagerConfig, fetchAlertGroups, updateAlertManagerConfig } from '../api/alertmanager';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { fetchAnnotations } from '../api/annotations';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
@@ -264,22 +253,6 @@ export const deleteAlertManagerConfigAction = createAsyncThunk(
}
);
interface TestReceiversOptions {
alertManagerSourceName: string;
receivers: Receiver[];
alert?: TestReceiversAlert;
}
export const testReceiversAction = createAsyncThunk(
'unifiedalerting/testReceivers',
({ alertManagerSourceName, receivers, alert }: TestReceiversOptions): Promise<void> => {
return withAppEvents(withSerializedError(testReceivers(alertManagerSourceName, receivers, alert)), {
errorMessage: 'Failed to send test alert.',
successMessage: 'Test alert sent.',
});
}
);
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
return rules.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration);
@@ -8,7 +8,6 @@ import {
fetchGrafanaAnnotationsAction,
fetchPromRulesAction,
fetchRulerRulesAction,
testReceiversAction,
updateAlertManagerConfigAction,
} from './actions';
@@ -23,7 +22,6 @@ export const reducer = combineReducers({
fetchAlertGroupsAction,
(alertManagerSourceName) => alertManagerSourceName
).reducer,
testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer,
managedAlertStateHistory: createAsyncSlice('managedAlertStateHistory', fetchGrafanaAnnotationsAction).reducer,
});
+2
View File
@@ -2885,6 +2885,8 @@
"notification-message": "Notification message",
"predefined-notification-message": "You will send a test notification that uses a predefined alert. If you have defined a custom template or message, for better results switch to <strong>custom</strong> notification message, from above.",
"send-test-notification": "Send test notification",
"test-failed": "Test notification failed",
"test-successful": "Test notification sent successfully",
"title-test-contact-point": "Test contact point"
},
"threshold": {