Alerting: ability to edit alertmanager config as json via UI (#37268)

This commit is contained in:
Domas 2021-07-28 09:21:42 +03:00 committed by GitHub
parent 5f41c2f334
commit 69dff96c1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 192 additions and 33 deletions

View File

@ -1,7 +1,7 @@
import React from 'react';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { getAllDataSources } from './utils/config';
import { fetchAlertManagerConfig, deleteAlertManagerConfig } from './api/alertmanager';
import { fetchAlertManagerConfig, deleteAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager';
import { configureStore } from 'app/store/configureStore';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import Admin from './Admin';
@ -9,12 +9,13 @@ 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 { byLabelText, 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';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
jest.mock('./api/alertmanager');
jest.mock('./api/grafana');
@ -26,6 +27,7 @@ const mocks = {
api: {
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig),
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
},
};
@ -55,7 +57,9 @@ const dataSources = {
const ui = {
confirmButton: byRole('button', { name: /Confirm Modal Danger Button/ }),
resetButton: byRole('button', { name: /Reset Alertmanager configuration/ }),
resetButton: byRole('button', { name: /Reset configuration/ }),
saveButton: byRole('button', { name: /Save/ }),
configInput: byLabelText<HTMLTextAreaElement>(/Configuration/),
};
describe('Alerting Admin', () => {
@ -83,4 +87,34 @@ describe('Alerting Admin', () => {
await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled());
expect(ui.confirmButton.query()).not.toBeInTheDocument();
});
it('Edit and save alertmanager config', async () => {
let savedConfig: AlertManagerCortexConfig | undefined = undefined;
const defaultConfig = {
template_files: {
foo: 'bar',
},
alertmanager_config: {},
};
const newConfig = {
template_files: {
bar: 'baz',
},
alertmanager_config: {},
};
mocks.api.fetchConfig.mockImplementation(() => Promise.resolve(savedConfig ?? defaultConfig));
mocks.api.updateAlertManagerConfig.mockResolvedValue();
await renderAdminPage(dataSources.alertManager.name);
const input = await ui.configInput.find();
expect(input.value).toEqual(JSON.stringify(defaultConfig, null, 2));
userEvent.clear(input);
await userEvent.type(input, JSON.stringify(newConfig, null, 2));
userEvent.click(ui.saveButton.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
await waitFor(() => expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3));
expect(input.value).toEqual(JSON.stringify(newConfig, null, 2));
});
});

View File

@ -1,18 +1,39 @@
import React, { useState } from 'react';
import { Button, ConfirmModal } from '@grafana/ui';
import React, { useEffect, useState, useMemo } from 'react';
import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } 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 {
deleteAlertManagerConfigAction,
fetchAlertManagerConfigAction,
updateAlertManagerConfigAction,
} from './state/actions';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { initialAsyncRequestState } from './utils/redux';
interface FormValues {
configJSON: string;
}
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 { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
const { result: config, loading: isLoadingConfig, error: loadingError } =
(alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState;
useEffect(() => {
if (alertManagerSourceName) {
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
}
}, [alertManagerSourceName, dispatch]);
const resetConfig = () => {
if (alertManagerSourceName) {
@ -21,29 +42,98 @@ export default function Admin(): JSX.Element {
setShowConfirmDeleteAMConfig(false);
};
const defaultValues = useMemo(
(): FormValues => ({
configJSON: config ? JSON.stringify(config, null, 2) : '',
}),
[config]
);
const loading = isDeleting || isLoadingConfig || isSaving;
const onSubmit = (values: FormValues) => {
if (alertManagerSourceName) {
dispatch(
updateAlertManagerConfigAction({
newConfig: JSON.parse(values.configJSON),
oldConfig: config,
alertManagerSourceName,
successMessage: 'Alertmanager configuration updated.',
refetch: true,
})
);
}
};
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)}
/>
{loadingError && !loading && (
<Alert severity="error" title="Error loading Alertmanager configuration">
{loadingError.message || 'Unknown error.'}
</Alert>
)}
{isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && (
<Alert severity="info" title="Resetting Alertmanager configuration">
It might take a while...
</Alert>
)}
{alertManagerSourceName && config && (
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
{({ register, errors }) => (
<>
<Field
disabled={loading}
label="Configuration"
invalid={!!errors.configJSON}
error={errors.configJSON?.message}
>
<TextArea
{...register('configJSON', {
required: { value: true, message: 'Required.' },
validate: (v) => {
try {
JSON.parse(v);
return true;
} catch (e) {
return e.message;
}
},
})}
id="configuration"
rows={25}
/>
</Field>
<HorizontalGroup>
<Button type="submit" variant="primary" disabled={loading}>
Save
</Button>
<Button
type="button"
disabled={loading}
variant="destructive"
onClick={() => setShowConfirmDeleteAMConfig(true)}
>
Reset configuration
</Button>
</HorizontalGroup>
{!!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)}
/>
)}
</>
)}
</>
</Form>
)}
</AlertingPageWrapper>
);

View File

@ -37,7 +37,7 @@ import {
} from '../api/ruler';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
import { makeAMLink } from '../utils/misc';
import { makeAMLink, retryWhile } from '../utils/misc';
import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
import {
@ -51,6 +51,9 @@ import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
import { backendSrv } from 'app/core/services/backend_srv';
import * as ruleId from '../utils/rule-id';
import { isEmpty } from 'lodash';
import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError';
const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000;
export const fetchPromRulesAction = createAsyncThunk(
'unifiedalerting/fetchPromRules',
@ -61,7 +64,13 @@ export const fetchAlertManagerConfigAction = createAsyncThunk(
'unifiedalerting/fetchAmConfig',
(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> =>
withSerializedError(
fetchAlertManagerConfig(alertManagerSourceName).then((result) => {
retryWhile(
() => fetchAlertManagerConfig(alertManagerSourceName),
// if config has been recently deleted, it takes a while for cortex start returning the default one.
// retry for a short while instead of failing
(e) => !!messageFromError(e)?.includes('alertmanager storage object not found'),
FETCH_CONFIG_RETRY_TIMEOUT
).then((result) => {
// if user config is empty for cortex alertmanager, try to get config from status endpoint
if (
isEmpty(result.alertmanager_config) &&
@ -580,10 +589,18 @@ 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.',
});
async (alertManagerSourceName: string, thunkAPI): Promise<void> => {
return withAppEvents(
withSerializedError(
(async () => {
await deleteAlertManagerConfig(alertManagerSourceName);
await thunkAPI.dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
})()
),
{
errorMessage: 'Failed to reset Alertmanager configuration',
successMessage: 'Alertmanager configuration reset.',
}
);
}
);

View File

@ -47,3 +47,21 @@ export function recordToArray(record: Record<string, string>): Array<{ key: stri
export function makeAMLink(path: string, alertManagerName?: string): string {
return `${path}${alertManagerName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${encodeURIComponent(alertManagerName)}` : ''}`;
}
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
export function retryWhile<T, E = Error>(
fn: () => Promise<T>,
shouldRetry: (e: E) => boolean,
timeout: number, // milliseconds, how long to keep retrying
pause = 1000 // milliseconds, pause between retries
): Promise<T> {
const start = new Date().getTime();
const makeAttempt = (): Promise<T> =>
fn().catch((e) => {
if (shouldRetry(e) && new Date().getTime() - start < timeout) {
return new Promise((resolve) => setTimeout(resolve, pause)).then(makeAttempt);
}
throw e;
});
return makeAttempt();
}