From cb9912ec0a798662e5f80f835f38ef80222697a1 Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 18 Aug 2021 10:16:35 +0300 Subject: [PATCH] Alerting: button to test contact point (#37475) --- packages/grafana-ui/src/types/icon.ts | 1 + pkg/services/ngalert/notifier/receivers.go | 5 +- .../alerting/unified/Receivers.test.tsx | 50 +++++++++++++++++-- .../alerting/unified/api/alertmanager.ts | 31 ++++++++++++ .../receivers/form/ChannelSubForm.tsx | 16 ++++++ .../receivers/form/GrafanaReceiverForm.tsx | 24 ++++++++- .../receivers/form/ReceiverForm.tsx | 10 ++++ .../components/rules/RuleListErrors.tsx | 8 ++- .../alerting/unified/state/actions.ts | 17 +++++++ .../alerting/unified/state/reducers.ts | 2 + .../alerting/unified/utils/receiver-form.ts | 2 +- .../plugins/datasource/alertmanager/types.ts | 20 ++++++++ 12 files changed, 176 insertions(+), 10 deletions(-) diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 36ca55dc71b..b2bb10fafd8 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -100,6 +100,7 @@ export const getAvailableIcons = () => 'list-ui-alt', 'list-ul', 'lock', + 'message', 'minus', 'minus-circle', 'mobile-android', diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go index 3e6e9230651..202c4954dbb 100644 --- a/pkg/services/ngalert/notifier/receivers.go +++ b/pkg/services/ngalert/notifier/receivers.go @@ -63,12 +63,11 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei testAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ - model.LabelName("alertname"): "TestAlertAlwaysFiring", + model.LabelName("alertname"): "TestAlert", model.LabelName("instance"): "Grafana", }, Annotations: model.LabelSet{ - model.LabelName("summary"): "TestAlertAlwaysFiring", - model.LabelName("description"): "This is a test alert from Grafana", + model.LabelName("summary"): "Notification test", }, StartsAt: now, }, diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index ab026276d24..55fb4be5ba1 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -4,10 +4,10 @@ import { Router } from 'react-router-dom'; import Receivers from './Receivers'; import React from 'react'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; -import { act, render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { getAllDataSources } from './utils/config'; import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; -import { updateAlertManagerConfig, fetchAlertManagerConfig, fetchStatus } from './api/alertmanager'; +import { updateAlertManagerConfig, fetchAlertManagerConfig, fetchStatus, testReceivers } from './api/alertmanager'; import { mockDataSource, MockDataSourceSrv, @@ -37,6 +37,7 @@ const mocks = { fetchStatus: typeAsJestMock(fetchStatus), updateConfig: typeAsJestMock(updateAlertManagerConfig), fetchNotifiers: typeAsJestMock(fetchNotifiers), + testReceivers: typeAsJestMock(testReceivers), }, }; @@ -68,6 +69,7 @@ const ui = { newContactPointButton: byRole('link', { name: /new contact point/i }), saveContactButton: byRole('button', { name: /save contact point/i }), newContactPointTypeButton: byRole('button', { name: /new contact point type/i }), + testContactPointButton: byRole('button', { name: /Test/ }), receiversTable: byTestId('receivers-table'), templatesTable: byTestId('templates-table'), @@ -78,7 +80,7 @@ const ui = { inputs: { name: byLabelText('Name'), email: { - addresses: byLabelText('Addresses'), + addresses: byLabelText(/Addresses/), }, hipchat: { url: byLabelText('Hip Chat Url'), @@ -149,6 +151,46 @@ describe('Receivers', () => { expect(locationService.getSearchObject()[ALERTMANAGER_NAME_QUERY_KEY]).toEqual('CloudManager'); }); + it('Grafana receiver can be tested', async () => { + mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); + + await renderReceivers(); + + // go to new contact point page + userEvent.click(await ui.newContactPointButton.find()); + + await byRole('heading', { name: /create contact point/i }).find(); + expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/new'); + + // type in a name for the new receiver + await userEvent.type(ui.inputs.name.get(), 'my new receiver'); + + // enter some email + const email = ui.inputs.email.addresses.get(); + userEvent.clear(email); + await userEvent.type(email, 'tester@grafana.com'); + + // try to test the contact point + userEvent.click(ui.testContactPointButton.get()); + + await waitFor(() => expect(mocks.api.testReceivers).toHaveBeenCalled()); + + expect(mocks.api.testReceivers).toHaveBeenCalledWith('grafana', [ + { + grafana_managed_receiver_configs: [ + { + disableResolveMessage: false, + name: 'test', + secureSettings: {}, + settings: { addresses: 'tester@grafana.com', singleEmail: false }, + type: 'email', + }, + ], + name: 'test', + }, + ]); + }); + it('Grafana receiver can be created', async () => { mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig); mocks.api.updateConfig.mockResolvedValue(); @@ -164,7 +206,7 @@ describe('Receivers', () => { await userEvent.type(byLabelText('Name').get(), 'my new receiver'); // check that default email form is rendered - await ui.inputs.name.find(); + await ui.inputs.email.addresses.find(); // select hipchat await clickSelectOption(byTestId('items.0.type').get(), 'HipChat'); diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 2b67c87b209..c75cdf491a0 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -8,6 +8,9 @@ import { SilenceCreatePayload, Matcher, AlertmanagerStatus, + Receiver, + TestReceiversPayload, + TestReceiversResult, } from 'app/plugins/datasource/alertmanager/types'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -155,6 +158,34 @@ export async function fetchStatus(alertManagerSourceName: string): Promise { + const data: TestReceiversPayload = { + receivers, + }; + const result = await getBackendSrv() + .fetch({ + method: 'POST', + data, + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/receivers/test`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); + + // api returns 207 if one or more receivers has failed test. Collect errors in this case + if (result.status === 207) { + throw new Error( + result.data.receivers + .flatMap((receiver) => + receiver.grafana_managed_receiver_configs + .filter((receiver) => receiver.status === 'failed') + .map((receiver) => receiver.error ?? 'Unknown error.') + ) + .join('; ') + ); + } +} + function escapeQuotes(value: string): string { return value.replace(/"/g, '\\"'); } diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index a190b0d2f66..92dacaa1e1b 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -7,12 +7,14 @@ import { useFormContext, FieldErrors } from 'react-hook-form'; import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form'; import { ChannelOptions } from './ChannelOptions'; import { CollapsibleSection } from './CollapsibleSection'; +import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; interface Props { defaultValues: R; pathPrefix: string; notifiers: NotifierDTO[]; onDuplicate: () => void; + onTest?: () => void; commonSettingsComponent: CommonSettingsComponentType; secureFields?: Record; @@ -25,6 +27,7 @@ export function ChannelSubForm({ pathPrefix, onDuplicate, onDelete, + onTest, notifiers, errors, secureFields, @@ -34,6 +37,7 @@ export function ChannelSubForm({ const name = (fieldName: string) => `${pathPrefix}${fieldName}`; const { control, watch, register } = useFormContext(); const selectedType = watch(name('type')) ?? defaultValues.type; // nope, setting "default" does not work at all. + const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers); useEffect(() => { register(`${pathPrefix}.__id`); @@ -89,6 +93,18 @@ export function ChannelSubForm({
+ {onTest && ( + + )} diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index f70c193135b..dc5c3321e0a 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -7,10 +7,15 @@ import { import React, { FC, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; -import { fetchGrafanaNotifiersAction, updateAlertManagerConfigAction } from '../../../state/actions'; +import { + fetchGrafanaNotifiersAction, + testReceiversAction, + updateAlertManagerConfigAction, +} from '../../../state/actions'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { + formChannelValuesToGrafanaChannelConfig, formValuesToGrafanaReceiver, grafanaReceiverToFormValues, updateConfigWithReceiver, @@ -68,6 +73,22 @@ export const GrafanaReceiverForm: FC = ({ existing, alertManagerSourceNam ); }; + const onTestChannel = (values: GrafanaChannelValues) => { + const existing: GrafanaManagedReceiverConfig | undefined = id2original[values.__id]; + const chan = formChannelValuesToGrafanaChannelConfig(values, defaultChannelValues, 'test', existing); + dispatch( + testReceiversAction({ + alertManagerSourceName, + receivers: [ + { + name: 'test', + grafana_managed_receiver_configs: [chan], + }, + ], + }) + ); + }; + const takenReceiverNames = useMemo( () => config.alertmanager_config.receivers?.map(({ name }) => name).filter((name) => name !== existing?.name) ?? [], [config, existing] @@ -79,6 +100,7 @@ export const GrafanaReceiverForm: FC = ({ existing, alertManagerSourceNam config={config} onSubmit={onSubmit} initialValues={existingValue} + onTestChannel={onTestChannel} notifiers={grafanaNotifiers.result} alertManagerSourceName={alertManagerSourceName} defaultItem={defaultChannelValues} diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index d7dc4a7606a..adf8a343058 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -19,6 +19,7 @@ interface Props { notifiers: NotifierDTO[]; defaultItem: R; alertManagerSourceName: string; + onTestChannel?: (channel: R) => void; onSubmit: (values: ReceiverFormValues) => void; takenReceiverNames: string[]; // will validate that user entered receiver name is not one of these commonSettingsComponent: CommonSettingsComponentType; @@ -32,6 +33,7 @@ export function ReceiverForm({ notifiers, alertManagerSourceName, onSubmit, + onTestChannel, takenReceiverNames, commonSettingsComponent, }: Props): JSX.Element { @@ -117,6 +119,14 @@ export function ReceiverForm({ const currentValues: R = getValues().items[index]; append({ ...currentValues, __id: String(Math.random()) }); }} + onTest={ + onTestChannel + ? () => { + const currentValues: R = getValues().items[index]; + onTestChannel(currentValues); + } + : undefined + } onDelete={() => remove(index)} pathPrefix={pathPrefix} notifiers={notifiers} diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index 1c26499d838..88442dce045 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -75,7 +75,13 @@ export function RuleListErrors(): ReactElement { <>
{errors[0]}
{errors.length >= 2 && ( - )} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 1ea5bf6a545..7166a8951ad 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -4,6 +4,7 @@ import { AlertmanagerAlert, AlertManagerCortexConfig, AlertmanagerGroup, + Receiver, Silence, SilenceCreatePayload, } from 'app/plugins/datasource/alertmanager/types'; @@ -26,6 +27,7 @@ import { updateAlertManagerConfig, fetchStatus, deleteAlertManagerConfig, + testReceivers, } from '../api/alertmanager'; import { fetchRules } from '../api/prometheus'; import { @@ -604,3 +606,18 @@ export const deleteAlertManagerConfigAction = createAsyncThunk( ); } ); + +interface TestReceiversOptions { + alertManagerSourceName: string; + receivers: Receiver[]; +} + +export const testReceiversAction = createAsyncThunk( + 'unifiedalerting/testReceivers', + ({ alertManagerSourceName, receivers }: TestReceiversOptions): Promise => { + return withAppEvents(withSerializedError(testReceivers(alertManagerSourceName, receivers)), { + errorMessage: 'Failed to send test alert.', + successMessage: 'Test alert sent.', + }); + } +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 09793108dc3..896860a217d 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -15,6 +15,7 @@ import { fetchAlertGroupsAction, checkIfLotexSupportsEditingRulesAction, deleteAlertManagerConfigAction, + testReceiversAction, } from './actions'; export const reducer = combineReducers({ @@ -48,6 +49,7 @@ export const reducer = combineReducers({ checkIfLotexSupportsEditingRulesAction, (source) => source ).reducer, + testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer, }); export type UnifiedAlertingState = ReturnType; diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index 9a83dd73591..801e5bfc997 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -207,7 +207,7 @@ function grafanaChannelConfigToFormChannelValues( return values; } -function formChannelValuesToGrafanaChannelConfig( +export function formChannelValuesToGrafanaChannelConfig( values: GrafanaChannelValues, defaults: GrafanaChannelValues, name: string, diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index cac8e670e25..7c26a65aa38 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -226,3 +226,23 @@ export interface AlertmanagerStatus { version: string; }; } + +export interface TestReceiversPayload { + receivers?: Receiver[]; +} + +interface TestReceiversResultGrafanaReceiverConfig { + name: string; + uid?: string; + error?: string; + status: 'failed'; +} + +interface TestReceiversResultReceiver { + name: string; + grafana_managed_receiver_configs: TestReceiversResultGrafanaReceiverConfig[]; +} +export interface TestReceiversResult { + notified_at: string; + receivers: TestReceiversResultReceiver[]; +}