From 1881de82364ea7583411231e44a90a7c62871bc4 Mon Sep 17 00:00:00 2001 From: Domas Date: Thu, 22 Jul 2021 09:15:39 +0300 Subject: [PATCH] Alerting: add button to deactivate current alertmanager configuration (#36951) * reset alert manager config button for admins * "alert manager" -> "Alertmanager" --- pkg/api/index.go | 6 ++ .../features/alerting/unified/Admin.test.tsx | 86 +++++++++++++++++++ .../app/features/alerting/unified/Admin.tsx | 50 +++++++++++ .../alerting/unified/AmNotifications.test.tsx | 2 +- .../alerting/unified/AmNotifications.tsx | 2 +- .../alerting/unified/AmRoutes.test.tsx | 2 +- .../features/alerting/unified/AmRoutes.tsx | 4 +- .../alerting/unified/Receivers.test.tsx | 2 +- .../features/alerting/unified/Receivers.tsx | 2 +- .../alerting/unified/api/alertmanager.ts | 11 +++ .../unified/components/AlertManagerPicker.tsx | 2 +- .../receivers/ReceiversAndTemplatesView.tsx | 2 +- .../receivers/ReceiversTable.test.tsx | 2 +- .../hooks/useAlertManagerSourceName.ts | 2 +- .../alerting/unified/state/actions.ts | 22 ++++- .../alerting/unified/state/reducers.ts | 2 + .../alerting/unified/utils/receivers.ts | 2 +- public/app/routes/routes.tsx | 7 ++ 18 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 public/app/features/alerting/unified/Admin.test.tsx create mode 100644 public/app/features/alerting/unified/Admin.tsx diff --git a/pkg/api/index.go b/pkg/api/index.go index 6465fce8086..fe4ccf416f5 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -227,6 +227,12 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } } + if c.OrgRole == models.ROLE_ADMIN { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{ + Text: "Admin", Id: "alerting-admin", Url: hs.Cfg.AppSubURL + "/alerting/admin", + Icon: "cog", + }) + } navTree = append(navTree, &dtos.NavLink{ Text: "Alerting", diff --git a/public/app/features/alerting/unified/Admin.test.tsx b/public/app/features/alerting/unified/Admin.test.tsx new file mode 100644 index 00000000000..d27b4ebe750 --- /dev/null +++ b/public/app/features/alerting/unified/Admin.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { getAllDataSources } from './utils/config'; +import { fetchAlertManagerConfig, deleteAlertManagerConfig } from './api/alertmanager'; +import { configureStore } from 'app/store/configureStore'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import Admin from './Admin'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; +import { render, waitFor } from '@testing-library/react'; +import { byRole } from 'testing-library-selector'; +import { mockDataSource, MockDataSourceSrv } from './mocks'; +import { DataSourceType } from './utils/datasource'; +import { contextSrv } from 'app/core/services/context_srv'; +import store from 'app/core/store'; +import userEvent from '@testing-library/user-event'; + +jest.mock('./api/alertmanager'); +jest.mock('./api/grafana'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSources: typeAsJestMock(getAllDataSources), + + api: { + fetchConfig: typeAsJestMock(fetchAlertManagerConfig), + deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig), + }, +}; + +const renderAdminPage = (alertManagerSourceName?: string) => { + const store = configureStore(); + + locationService.push( + '/alerting/notifications' + + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '') + ); + + return render( + + + + + + ); +}; + +const dataSources = { + alertManager: mockDataSource({ + name: 'CloudManager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + confirmButton: byRole('button', { name: /Confirm Modal Danger Button/ }), + resetButton: byRole('button', { name: /Reset Alertmanager configuration/ }), +}; + +describe('Alerting Admin', () => { + beforeEach(() => { + jest.resetAllMocks(); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + contextSrv.isGrafanaAdmin = true; + store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); + }); + + it('Reset alertmanager config', async () => { + mocks.api.fetchConfig.mockResolvedValue({ + template_files: { + foo: 'bar', + }, + alertmanager_config: {}, + }); + mocks.api.deleteAlertManagerConfig.mockResolvedValue(); + + await renderAdminPage(dataSources.alertManager.name); + + userEvent.click(await ui.resetButton.find()); + userEvent.click(ui.confirmButton.get()); + await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled()); + expect(ui.confirmButton.query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx new file mode 100644 index 00000000000..5a98d876bee --- /dev/null +++ b/public/app/features/alerting/unified/Admin.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { Button, ConfirmModal } from '@grafana/ui'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { useDispatch } from 'react-redux'; +import { deleteAlertManagerConfigAction } from './state/actions'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; + +export default function Admin(): JSX.Element { + const dispatch = useDispatch(); + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false); + const { loading } = useUnifiedAlertingSelector((state) => state.deleteAMConfig); + + const resetConfig = () => { + if (alertManagerSourceName) { + dispatch(deleteAlertManagerConfigAction(alertManagerSourceName)); + } + setShowConfirmDeleteAMConfig(false); + }; + + return ( + + + {alertManagerSourceName && ( + <> + + {!!showConfirmDeleteAMConfig && ( + setShowConfirmDeleteAMConfig(false)} + /> + )} + + )} + + ); +} diff --git a/public/app/features/alerting/unified/AmNotifications.test.tsx b/public/app/features/alerting/unified/AmNotifications.test.tsx index 63396b86639..41c6ef4bb30 100644 --- a/public/app/features/alerting/unified/AmNotifications.test.tsx +++ b/public/app/features/alerting/unified/AmNotifications.test.tsx @@ -34,7 +34,7 @@ const renderAmNotifications = () => { const dataSources = { am: mockDataSource({ - name: 'Alert Manager', + name: 'Alertmanager', type: DataSourceType.Alertmanager, }), }; diff --git a/public/app/features/alerting/unified/AmNotifications.tsx b/public/app/features/alerting/unified/AmNotifications.tsx index c7a3e56a0d6..7533708c6eb 100644 --- a/public/app/features/alerting/unified/AmNotifications.tsx +++ b/public/app/features/alerting/unified/AmNotifications.tsx @@ -12,7 +12,7 @@ import { initialAsyncRequestState } from './utils/redux'; import { AmNotificationsGroup } from './components/amnotifications/AmNotificationsGroup'; import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; -import { Alert, LoadingPlaceholder } from '../../../../../packages/grafana-ui/src'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; const AlertManagerNotifications = () => { const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx index cedc2b07bb9..0f103cc2964 100644 --- a/public/app/features/alerting/unified/AmRoutes.test.tsx +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -55,7 +55,7 @@ const renderAmRoutes = () => { const dataSources = { am: mockDataSource({ - name: 'Alert Manager', + name: 'Alertmanager', type: DataSourceType.Alertmanager, }), }; diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index 47a33c4c91f..b64ebdd0f88 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -92,11 +92,11 @@ const AmRoutes: FC = () => { {resultError && !resultLoading && ( - + {resultError.message || 'Unknown error.'} )} - {resultLoading && } + {resultLoading && } {result && !resultLoading && !resultError && ( <> { store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY); }); - it('Template and receiver tables are rendered, alert manager can be selected', async () => { + it('Template and receiver tables are rendered, alertmanager can be selected', async () => { mocks.api.fetchConfig.mockImplementation((name) => Promise.resolve(name === GRAFANA_RULES_SOURCE_NAME ? someGrafanaAlertManagerConfig : someCloudAlertManagerConfig) ); diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 46bc85d0cec..1d148d92f59 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -57,7 +57,7 @@ const Receivers: FC = () => { onChange={setAlertManagerSourceName} /> {error && !loading && ( - + {error.message || 'Unknown error.'} )} diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 5689a46f671..2b67c87b209 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -55,6 +55,17 @@ export async function updateAlertManagerConfig( .toPromise(); } +export async function deleteAlertManagerConfig(alertManagerSourceName: string): Promise { + await getBackendSrv() + .fetch({ + method: 'DELETE', + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/alerts`, + showErrorAlert: false, + showSuccessAlert: false, + }) + .toPromise(); +} + export async function fetchSilences(alertManagerSourceName: string): Promise { const result = await getBackendSrv() .fetch({ diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index 4ba5083af21..f276e2f6c21 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -41,7 +41,7 @@ export const AlertManagerPicker: FC = ({ onChange, current, disabled = fa return ( diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx index c13dfa40271..a6e4ee748bc 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx @@ -23,7 +23,7 @@ export const ReceiversAndTemplatesView: FC = ({ config, alertManagerName {isCloud && (

- For each external alert managers you can define global settings, like server addresses, usernames and + For each external Alertmanager you can define global settings, like server addresses, usernames and password, for all the supported contact points.

diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx index 646ffea4e3e..fa43942699b 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.test.tsx @@ -81,7 +81,7 @@ describe('ReceiversTable', () => { expect(rows[1].querySelectorAll('td')[1].textContent).toEqual(''); }); - it('render receivers with alert manager notifers', async () => { + it('render receivers with alertmanager notifers', async () => { const receivers: Receiver[] = [ { name: 'with receivers', diff --git a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts b/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts index 9e8cc60ff79..9ffbcd069f4 100644 --- a/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts +++ b/public/app/features/alerting/unified/hooks/useAlertManagerSourceName.ts @@ -40,7 +40,7 @@ export function useAlertManagerSourceName(): [string | undefined, (alertManagerS if (isAlertManagerSource(querySource)) { return [querySource, update]; } else { - // non existing alert manager + // non existing alertmanager return [undefined, update]; } } diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 091b36708c2..30475c453fa 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -25,6 +25,7 @@ import { createOrUpdateSilence, updateAlertManagerConfig, fetchStatus, + deleteAlertManagerConfig, } from '../api/alertmanager'; import { fetchRules } from '../api/prometheus'; import { @@ -62,7 +63,11 @@ export const fetchAlertManagerConfigAction = createAsyncThunk( withSerializedError( fetchAlertManagerConfig(alertManagerSourceName).then((result) => { // if user config is empty for cortex alertmanager, try to get config from status endpoint - if (isEmpty(result.alertmanager_config) && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { + if ( + isEmpty(result.alertmanager_config) && + isEmpty(result.template_files) && + alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME + ) { return fetchStatus(alertManagerSourceName).then((status) => ({ alertmanager_config: status.config, template_files: {}, @@ -404,7 +409,10 @@ export const updateAlertManagerConfigAction = createAsyncThunk { const latestConfig = await fetchAlertManagerConfig(alertManagerSourceName); - if (JSON.stringify(latestConfig) !== JSON.stringify(oldConfig)) { + if ( + !(isEmpty(latestConfig.alertmanager_config) && isEmpty(latestConfig.template_files)) && + JSON.stringify(latestConfig) !== JSON.stringify(oldConfig) + ) { throw new Error( 'It seems configuration has been recently updated. Please reload page and try again to make sure that recent changes are not overwritten.' ); @@ -569,3 +577,13 @@ export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk( } ) ); + +export const deleteAlertManagerConfigAction = createAsyncThunk( + 'unifiedalerting/deleteAlertManagerConfig', + async (alertManagerSourceName: string): Promise => { + return withAppEvents(withSerializedError(deleteAlertManagerConfig(alertManagerSourceName)), { + errorMessage: 'Failed to reset Alertmanager configuration', + successMessage: 'Alertmanager configuration reset.', + }); + } +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 8197289461f..09793108dc3 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -14,6 +14,7 @@ import { fetchFolderAction, fetchAlertGroupsAction, checkIfLotexSupportsEditingRulesAction, + deleteAlertManagerConfigAction, } from './actions'; export const reducer = combineReducers({ @@ -32,6 +33,7 @@ export const reducer = combineReducers({ }), grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, + deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer, updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer, amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName) .reducer, diff --git a/public/app/features/alerting/unified/utils/receivers.ts b/public/app/features/alerting/unified/utils/receivers.ts index 44f0d8a7e89..fbde0d532ea 100644 --- a/public/app/features/alerting/unified/utils/receivers.ts +++ b/public/app/features/alerting/unified/utils/receivers.ts @@ -16,7 +16,7 @@ export function extractNotifierTypeCounts(receiver: Receiver, grafanaNotifiers: function getCortexAlertManagerNotifierTypeCounts(receiver: Receiver): NotifierTypeCounts { return Object.entries(receiver) - .filter(([key]) => key !== 'grafana_managed_receiver_configs' && key.endsWith('_configs')) // filter out only properties that are alert manager notifier + .filter(([key]) => key !== 'grafana_managed_receiver_configs' && key.endsWith('_configs')) // filter out only properties that are alertmanager notifier .filter(([_, value]) => Array.isArray(value) && !!value.length) // check that there are actually notifiers of this type configured .reduce((acc, [key, value]) => { const type = key.replace('_configs', ''); // remove the `_config` part from the key, making it intto a notifier name diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index cad127c3b0f..96b340529ea 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -466,6 +466,13 @@ export function getAppRoutes(): RouteDescriptor[] { import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') ), }, + { + path: '/alerting/admin', + roles: () => ['Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingAdmin" */ 'app/features/alerting/unified/Admin') + ), + }, { path: '/playlists', component: SafeDynamicImport(