mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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 React from 'react';
|
||||||
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
||||||
import { getAllDataSources } from './utils/config';
|
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 { configureStore } from 'app/store/configureStore';
|
||||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import Admin from './Admin';
|
import Admin from './Admin';
|
||||||
@ -9,12 +9,13 @@ import { Provider } from 'react-redux';
|
|||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||||
import { render, waitFor } from '@testing-library/react';
|
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 { mockDataSource, MockDataSourceSrv } from './mocks';
|
||||||
import { DataSourceType } from './utils/datasource';
|
import { DataSourceType } from './utils/datasource';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
jest.mock('./api/alertmanager');
|
jest.mock('./api/alertmanager');
|
||||||
jest.mock('./api/grafana');
|
jest.mock('./api/grafana');
|
||||||
@ -26,6 +27,7 @@ const mocks = {
|
|||||||
api: {
|
api: {
|
||||||
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
|
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
|
||||||
deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig),
|
deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig),
|
||||||
|
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,7 +57,9 @@ const dataSources = {
|
|||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
confirmButton: byRole('button', { name: /Confirm Modal Danger Button/ }),
|
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', () => {
|
describe('Alerting Admin', () => {
|
||||||
@ -83,4 +87,34 @@ describe('Alerting Admin', () => {
|
|||||||
await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled());
|
await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled());
|
||||||
expect(ui.confirmButton.query()).not.toBeInTheDocument();
|
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 React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { Button, ConfirmModal } from '@grafana/ui';
|
import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } from '@grafana/ui';
|
||||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { deleteAlertManagerConfigAction } from './state/actions';
|
import {
|
||||||
|
deleteAlertManagerConfigAction,
|
||||||
|
fetchAlertManagerConfigAction,
|
||||||
|
updateAlertManagerConfigAction,
|
||||||
|
} from './state/actions';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
|
import { initialAsyncRequestState } from './utils/redux';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
configJSON: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Admin(): JSX.Element {
|
export default function Admin(): JSX.Element {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||||
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
|
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 = () => {
|
const resetConfig = () => {
|
||||||
if (alertManagerSourceName) {
|
if (alertManagerSourceName) {
|
||||||
@ -21,14 +42,81 @@ export default function Admin(): JSX.Element {
|
|||||||
setShowConfirmDeleteAMConfig(false);
|
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 (
|
return (
|
||||||
<AlertingPageWrapper pageId="alerting-admin">
|
<AlertingPageWrapper pageId="alerting-admin">
|
||||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||||
{alertManagerSourceName && (
|
{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 }) => (
|
||||||
<>
|
<>
|
||||||
<Button disabled={loading} variant="destructive" onClick={() => setShowConfirmDeleteAMConfig(true)}>
|
<Field
|
||||||
Reset Alertmanager configuration
|
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>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowConfirmDeleteAMConfig(true)}
|
||||||
|
>
|
||||||
|
Reset configuration
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
{!!showConfirmDeleteAMConfig && (
|
{!!showConfirmDeleteAMConfig && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
@ -45,6 +133,8 @@ export default function Admin(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ import {
|
|||||||
} from '../api/ruler';
|
} from '../api/ruler';
|
||||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
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 { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
|
||||||
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
import { formValuesToRulerAlertingRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||||
import {
|
import {
|
||||||
@ -51,6 +51,9 @@ import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
|||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import * as ruleId from '../utils/rule-id';
|
import * as ruleId from '../utils/rule-id';
|
||||||
import { isEmpty } from 'lodash';
|
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(
|
export const fetchPromRulesAction = createAsyncThunk(
|
||||||
'unifiedalerting/fetchPromRules',
|
'unifiedalerting/fetchPromRules',
|
||||||
@ -61,7 +64,13 @@ export const fetchAlertManagerConfigAction = createAsyncThunk(
|
|||||||
'unifiedalerting/fetchAmConfig',
|
'unifiedalerting/fetchAmConfig',
|
||||||
(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> =>
|
(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> =>
|
||||||
withSerializedError(
|
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 user config is empty for cortex alertmanager, try to get config from status endpoint
|
||||||
if (
|
if (
|
||||||
isEmpty(result.alertmanager_config) &&
|
isEmpty(result.alertmanager_config) &&
|
||||||
@ -580,10 +589,18 @@ export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk(
|
|||||||
|
|
||||||
export const deleteAlertManagerConfigAction = createAsyncThunk(
|
export const deleteAlertManagerConfigAction = createAsyncThunk(
|
||||||
'unifiedalerting/deleteAlertManagerConfig',
|
'unifiedalerting/deleteAlertManagerConfig',
|
||||||
async (alertManagerSourceName: string): Promise<void> => {
|
async (alertManagerSourceName: string, thunkAPI): Promise<void> => {
|
||||||
return withAppEvents(withSerializedError(deleteAlertManagerConfig(alertManagerSourceName)), {
|
return withAppEvents(
|
||||||
|
withSerializedError(
|
||||||
|
(async () => {
|
||||||
|
await deleteAlertManagerConfig(alertManagerSourceName);
|
||||||
|
await thunkAPI.dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||||
|
})()
|
||||||
|
),
|
||||||
|
{
|
||||||
errorMessage: 'Failed to reset Alertmanager configuration',
|
errorMessage: 'Failed to reset Alertmanager configuration',
|
||||||
successMessage: 'Alertmanager configuration reset.',
|
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 {
|
export function makeAMLink(path: string, alertManagerName?: string): string {
|
||||||
return `${path}${alertManagerName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${encodeURIComponent(alertManagerName)}` : ''}`;
|
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