Alerting: add button to deactivate current alertmanager configuration (#36951)

* reset alert manager config button for admins

* "alert manager" -> "Alertmanager"
This commit is contained in:
Domas 2021-07-22 09:15:39 +03:00 committed by GitHub
parent 8d06aaaf09
commit 1881de8236
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 194 additions and 14 deletions

View File

@ -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",

View File

@ -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(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Admin />
</Router>
</Provider>
);
};
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();
});
});

View File

@ -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 (
<AlertingPageWrapper pageId="alerting-admin">
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
{alertManagerSourceName && (
<>
<Button disabled={loading} variant="destructive" onClick={() => setShowConfirmDeleteAMConfig(true)}>
Reset Alertmanager configuration
</Button>
{!!showConfirmDeleteAMConfig && (
<ConfirmModal
isOpen={true}
title="Reset Alertmanager configuration"
body={`Are you sure you want to reset configuration ${
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME
? 'for the Grafana Alertmanager'
: `for "${alertManagerSourceName}"`
}? Contact points and notification policies will be reset to their defaults.`}
confirmText="Yes, reset configuration"
onConfirm={resetConfig}
onDismiss={() => setShowConfirmDeleteAMConfig(false)}
/>
)}
</>
)}
</AlertingPageWrapper>
);
}

View File

@ -34,7 +34,7 @@ const renderAmNotifications = () => {
const dataSources = {
am: mockDataSource({
name: 'Alert Manager',
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
}),
};

View File

@ -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();

View File

@ -55,7 +55,7 @@ const renderAmRoutes = () => {
const dataSources = {
am: mockDataSource({
name: 'Alert Manager',
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
}),
};

View File

@ -92,11 +92,11 @@ const AmRoutes: FC = () => {
<AlertingPageWrapper pageId="am-routes">
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
{resultError && !resultLoading && (
<Alert severity="error" title="Error loading alert manager config">
<Alert severity="error" title="Error loading Alertmanager config">
{resultError.message || 'Unknown error.'}
</Alert>
)}
{resultLoading && <LoadingPlaceholder text="Loading alert manager config..." />}
{resultLoading && <LoadingPlaceholder text="Loading Alertmanager config..." />}
{result && !resultLoading && !resultError && (
<>
<AmRootRoute

View File

@ -108,7 +108,7 @@ describe('Receivers', () => {
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)
);

View File

@ -57,7 +57,7 @@ const Receivers: FC = () => {
onChange={setAlertManagerSourceName}
/>
{error && !loading && (
<Alert severity="error" title="Error loading alert manager config">
<Alert severity="error" title="Error loading Alertmanager config">
{error.message || 'Unknown error.'}
</Alert>
)}

View File

@ -55,6 +55,17 @@ export async function updateAlertManagerConfig(
.toPromise();
}
export async function deleteAlertManagerConfig(alertManagerSourceName: string): Promise<void> {
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<Silence[]> {
const result = await getBackendSrv()
.fetch<Silence[]>({

View File

@ -41,7 +41,7 @@ export const AlertManagerPicker: FC<Props> = ({ onChange, current, disabled = fa
return (
<Field
className={styles.field}
label={disabled ? 'Alert manager' : 'Choose alert manager'}
label={disabled ? 'Alertmanager' : 'Choose Alertmanager'}
disabled={disabled}
data-testid="alertmanager-picker"
>

View File

@ -23,7 +23,7 @@ export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName
{isCloud && (
<Alert className={styles.section} severity="info" title="Global config for contact points">
<p>
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.
</p>
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">

View File

@ -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',

View File

@ -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];
}
}

View File

@ -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<void, UpdateAlert
withSerializedError(
(async () => {
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<void> => {
return withAppEvents(withSerializedError(deleteAlertManagerConfig(alertManagerSourceName)), {
errorMessage: 'Failed to reset Alertmanager configuration',
successMessage: 'Alertmanager configuration reset.',
});
}
);

View File

@ -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,

View File

@ -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<NotifierTypeCounts>((acc, [key, value]) => {
const type = key.replace('_configs', ''); // remove the `_config` part from the key, making it intto a notifier name

View File

@ -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(