mirror of
https://github.com/grafana/grafana.git
synced 2025-02-14 01:23:32 -06:00
Alerting: ability to edit alertmanager config as json via UI (#37268)
This commit is contained in:
parent
5f41c2f334
commit
69dff96c1b
@ -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));
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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.',
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user