From 67f6611d85530e6612d864e574f3892e323c6987 Mon Sep 17 00:00:00 2001 From: Domas Date: Fri, 23 Apr 2021 15:54:31 +0300 Subject: [PATCH] Alerting: receivers table in the receivers page (#33119) --- public/app/core/hooks/useQueryParams.ts | 5 +- .../features/alerting/unified/Receivers.tsx | 18 ++- .../features/alerting/unified/api/grafana.ts | 6 + .../components/receivers/ReceiversSection.tsx | 1 + .../receivers/ReceiversTable.test.tsx | 125 ++++++++++++++++++ .../components/receivers/ReceiversTable.tsx | 68 ++++++++++ .../alerting/unified/state/actions.ts | 8 +- .../alerting/unified/state/reducers.ts | 2 + .../alerting/unified/utils/receivers.ts | 31 +++++ .../features/alerting/unified/utils/redux.ts | 2 +- .../plugins/datasource/alertmanager/consts.ts | 9 ++ .../plugins/datasource/alertmanager/types.ts | 1 + 12 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 public/app/features/alerting/unified/api/grafana.ts create mode 100644 public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx create mode 100644 public/app/features/alerting/unified/utils/receivers.ts create mode 100644 public/app/plugins/datasource/alertmanager/consts.ts diff --git a/public/app/core/hooks/useQueryParams.ts b/public/app/core/hooks/useQueryParams.ts index 71815f44180..24771d2f793 100644 --- a/public/app/core/hooks/useQueryParams.ts +++ b/public/app/core/hooks/useQueryParams.ts @@ -6,6 +6,9 @@ import { useLocation } from 'react-use'; export function useQueryParams(): [UrlQueryMap, (values: UrlQueryMap, replace?: boolean) => void] { const { search } = useLocation(); const queryParams = useMemo(() => locationSearchToObject(search || ''), [search]); - const update = useCallback((values: UrlQueryMap, replace?: boolean) => locationService.partial(values, replace), []); + const update = useCallback( + (values: UrlQueryMap, replace?: boolean) => setImmediate(() => locationService.partial(values, replace)), + [] + ); return [queryParams, update]; } diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index f5a0fa9a286..8a8ea296a53 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -3,10 +3,12 @@ import React, { FC, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { ReceiversTable } from './components/receivers/ReceiversTable'; import { TemplatesTable } from './components/receivers/TemplatesTable'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertManagerConfigAction } from './state/actions'; +import { fetchAlertManagerConfigAction, fetchGrafanaNotifiersAction } from './state/actions'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { initialAsyncRequestState } from './utils/redux'; const Receivers: FC = () => { @@ -14,11 +16,18 @@ const Receivers: FC = () => { const dispatch = useDispatch(); const config = useUnifiedAlertingSelector((state) => state.amConfigs); + const receiverTypes = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); useEffect(() => { dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); }, [alertManagerSourceName, dispatch]); + useEffect(() => { + if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && !(receiverTypes.result || receiverTypes.loading)) { + dispatch(fetchGrafanaNotifiersAction()); + } + }, [alertManagerSourceName, dispatch, receiverTypes]); + const { result, loading, error } = config[alertManagerSourceName] || initialAsyncRequestState; return ( @@ -32,7 +41,12 @@ const Receivers: FC = () => { )} {loading && } - {result && !loading && !error && } + {result && !loading && !error && ( + <> + + + + )} ); }; diff --git a/public/app/features/alerting/unified/api/grafana.ts b/public/app/features/alerting/unified/api/grafana.ts new file mode 100644 index 00000000000..dcca81c1966 --- /dev/null +++ b/public/app/features/alerting/unified/api/grafana.ts @@ -0,0 +1,6 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { NotifierDTO } from 'app/types'; + +export function fetchNotifiers(): Promise { + return getBackendSrv().get(`/api/alert-notifiers`); +} diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx index e2be8fcb459..3c66bc81ede 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx @@ -27,6 +27,7 @@ export const ReceiversSection: FC = ({ title, description, addButtonLabel const getStyles = (theme: GrafanaTheme) => ({ heading: css` + margin-top: ${theme.spacing.xl}; display: flex; justify-content: space-between; `, diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx new file mode 100644 index 00000000000..075fc1d36af --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { + AlertManagerCortexConfig, + GrafanaManagedReceiverConfig, + Receiver, +} from 'app/plugins/datasource/alertmanager/types'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { ReceiversTable } from './ReceiversTable'; +import { fetchGrafanaNotifiersAction } from '../../state/actions'; +import { NotifierDTO, NotifierType } from 'app/types'; +import { byRole } from 'testing-library-selector'; + +const renderReceieversTable = async (receivers: Receiver[], notifiers: NotifierDTO[]) => { + const config: AlertManagerCortexConfig = { + template_files: {}, + alertmanager_config: { + receivers, + }, + }; + + const store = configureStore(); + await store.dispatch(fetchGrafanaNotifiersAction.fulfilled(notifiers, 'initial')); + + return render( + + + + ); +}; + +const mockGrafanaReceiver = (type: string): GrafanaManagedReceiverConfig => ({ + type, + id: 2, + frequency: 1, + disableResolveMessage: false, + secureFields: {}, + settings: {}, + sendReminder: false, + uid: '2', +}); + +const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({ + type, + name, + description: 'its a mock', + heading: 'foo', + options: [], +}); + +const ui = { + table: byRole('table'), +}; + +describe('ReceiversTable', () => { + it('render receivers with grafana notifiers', async () => { + const receivers: Receiver[] = [ + { + name: 'with receivers', + grafana_managed_receiver_configs: [mockGrafanaReceiver('googlechat'), mockGrafanaReceiver('sensugo')], + }, + { + name: 'without receivers', + grafana_managed_receiver_configs: [], + }, + ]; + + const notifiers: NotifierDTO[] = [mockNotifier('googlechat', 'Google Chat'), mockNotifier('sensugo', 'Sensu Go')]; + + await renderReceieversTable(receivers, notifiers); + + const table = await ui.table.find(); + + const rows = table.querySelector('tbody')?.querySelectorAll('tr')!; + expect(rows).toHaveLength(2); + expect(rows[0].querySelectorAll('td')[0]).toHaveTextContent('with receivers'); + expect(rows[0].querySelectorAll('td')[1]).toHaveTextContent('Google Chat, Sensu Go'); + expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('without receivers'); + expect(rows[1].querySelectorAll('td')[1].textContent).toEqual(''); + }); + + it('render receivers with alert manager notifers', async () => { + const receivers: Receiver[] = [ + { + name: 'with receivers', + email_configs: [ + { + to: 'domas.lapinskas@grafana.com', + }, + ], + slack_configs: [], + webhook_configs: [ + { + url: 'http://example.com', + }, + ], + opsgenie_configs: [ + { + foo: 'bar', + }, + ], + foo_configs: [ + { + url: 'bar', + }, + ], + }, + { + name: 'without receivers', + }, + ]; + + await renderReceieversTable(receivers, []); + + const table = await ui.table.find(); + + const rows = table.querySelector('tbody')?.querySelectorAll('tr')!; + expect(rows).toHaveLength(2); + expect(rows[0].querySelectorAll('td')[0]).toHaveTextContent('with receivers'); + expect(rows[0].querySelectorAll('td')[1]).toHaveTextContent('Email, Webhook, OpsGenie, Foo'); + expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('without receivers'); + expect(rows[1].querySelectorAll('td')[1].textContent).toEqual(''); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx new file mode 100644 index 00000000000..c52f6da675e --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -0,0 +1,68 @@ +import { useStyles } from '@grafana/ui'; +import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import React, { FC, useMemo } from 'react'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { getAlertTableStyles } from '../../styles/table'; +import { extractReadableNotifierTypes } from '../../utils/receivers'; +import { ActionButton } from '../rules/ActionButton'; +import { ActionIcon } from '../rules/ActionIcon'; +import { ReceiversSection } from './ReceiversSection'; + +interface Props { + config: AlertManagerCortexConfig; +} + +export const ReceiversTable: FC = ({ config }) => { + const tableStyles = useStyles(getAlertTableStyles); + + const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers); + + const rows = useMemo( + () => + config.alertmanager_config.receivers?.map((receiver) => ({ + name: receiver.name, + types: extractReadableNotifierTypes(receiver, grafanaNotifiers.result ?? []), + })) ?? [], + [config, grafanaNotifiers.result] + ); + + return ( + + + + + + + + + + + + + + + + {!rows.length && ( + + + + )} + {rows.map((receiver, idx) => ( + + + + + + ))} + +
Contact point nameTypeActions
No receivers defined.
{receiver.name}{receiver.types.join(', ')} + Edit + +
+
+ ); +}; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 6efeb3824c6..d659160ea06 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -3,7 +3,7 @@ import { locationService, config } from '@grafana/runtime'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { appEvents } from 'app/core/core'; import { AlertManagerCortexConfig, Silence } from 'app/plugins/datasource/alertmanager/types'; -import { ThunkResult } from 'app/types'; +import { NotifierDTO, ThunkResult } from 'app/types'; import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting'; import { PostableRulerRuleGroupDTO, @@ -12,6 +12,7 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; import { fetchAlertManagerConfig, fetchSilences } from '../api/alertmanager'; +import { fetchNotifiers } from '../api/grafana'; import { fetchRules } from '../api/prometheus'; import { deleteRulerRulesGroup, @@ -312,3 +313,8 @@ export const saveRuleFormAction = createAsyncThunk( })() ) ); + +export const fetchGrafanaNotifiersAction = createAsyncThunk( + 'unifiedalerting/fetchGrafanaNotifiers', + (): Promise => withSerializedError(fetchNotifiers()) +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index d4b9e93cefd..8a08e866950 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -3,6 +3,7 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux'; import { fetchAlertManagerConfigAction, fetchExistingRuleAction, + fetchGrafanaNotifiersAction, fetchPromRulesAction, fetchRulerRulesAction, fetchSilencesAction, @@ -23,6 +24,7 @@ export const reducer = combineReducers({ saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer, existingRule: createAsyncSlice('existingRule', fetchExistingRuleAction).reducer, }), + grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, }); export type UnifiedAlertingState = ReturnType; diff --git a/public/app/features/alerting/unified/utils/receivers.ts b/public/app/features/alerting/unified/utils/receivers.ts new file mode 100644 index 00000000000..cc2f026b243 --- /dev/null +++ b/public/app/features/alerting/unified/utils/receivers.ts @@ -0,0 +1,31 @@ +import { receiverTypeNames } from 'app/plugins/datasource/alertmanager/consts'; +import { GrafanaManagedReceiverConfig, Receiver } from 'app/plugins/datasource/alertmanager/types'; +import { NotifierDTO } from 'app/types'; +import { capitalize } from 'lodash'; + +// extract readable notifier types that are in use for a receiver, eg ['Slack', 'Email', 'PagerDuty'] +export function extractReadableNotifierTypes(receiver: Receiver, grafanaNotifiers: NotifierDTO[]): string[] { + return [ + // grafana specific receivers + ...getReadabaleGrafanaNotifierTypes(receiver.grafana_managed_receiver_configs ?? [], grafanaNotifiers), + // cortex alert manager receivers + ...getReadableCortexAlertManagerNotifierTypes(receiver), + ]; +} + +function getReadableCortexAlertManagerNotifierTypes(receiver: Receiver): string[] { + return Object.entries(receiver) + .filter(([key]) => key !== 'grafana_managed_receiver_configs' && key.endsWith('_configs')) // filter out only properties that are alert manager notifier + .filter(([_, value]) => Array.isArray(value) && !!value.length) // check that there are actually notifiers of this type configured + .map(([key]) => key.replace('_configs', '')) // remove the `_config` part from the key, making it intto a notifier name + .map((type) => receiverTypeNames[type] ?? capitalize(type)); // either map to readable name or, failing that, capitalize +} + +function getReadabaleGrafanaNotifierTypes( + configs: GrafanaManagedReceiverConfig[], + grafanaNotifiers: NotifierDTO[] +): string[] { + return configs + .map((recv) => recv.type) // extract types from config + .map((type) => grafanaNotifiers.find((r) => r.type === type)?.name ?? capitalize(type)); // get readable name from notifier cofnig, or if not available, just capitalize +} diff --git a/public/app/features/alerting/unified/utils/redux.ts b/public/app/features/alerting/unified/utils/redux.ts index be70c51d13a..c2863e7a16c 100644 --- a/public/app/features/alerting/unified/utils/redux.ts +++ b/public/app/features/alerting/unified/utils/redux.ts @@ -29,7 +29,7 @@ function requestStateReducer( requestId: action.meta.requestId, }; } else if (asyncThunk.fulfilled.match(action)) { - if (state.requestId === action.meta.requestId) { + if (state.requestId === undefined || state.requestId === action.meta.requestId) { return { ...state, result: action.payload as Draft, diff --git a/public/app/plugins/datasource/alertmanager/consts.ts b/public/app/plugins/datasource/alertmanager/consts.ts new file mode 100644 index 00000000000..97d3fc0dba5 --- /dev/null +++ b/public/app/plugins/datasource/alertmanager/consts.ts @@ -0,0 +1,9 @@ +export const receiverTypeNames: Record = { + pagerduty: 'PagerDuty', + pushover: 'Pushover', + slack: 'Slack', + opsgenie: 'OpsGenie', + webhook: 'Webhook', + victorops: 'VictorOps', + wechat: 'WeChat', +}; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 5b691d4424e..b28070b9b76 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -92,6 +92,7 @@ export type Receiver = { victorops_configs?: unknown[]; wechat_configs?: unknown[]; grafana_managed_receiver_configs?: GrafanaManagedReceiverConfig[]; + [key: string]: unknown; }; export type Route = {