mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Choose a previous valid AM configuration in case of error (#65746)
* Add new property to AlertmanagerConfig type * Implement fetching successfully applied configurations Added method to fetch them from the API and its corresponding action and reducer * Extract ConfigEditor as component to avoid code duplication * Display dropdown with valid configs upon error and allow to save them * Fix tests * Refactor to call new endpoint using RTK * Improve texts * Apply suggested refactor * Change constant casing * Only show config selector for Grafana AM * Remove ts-ignore * Move code together for simplicity * Remove invalid mock * Update endpoint and types based on backend changes * Rename property * Rename alermanager config property from backend changes * Disable editing old configurations Due to the latest backend changes, we no longer will provide the option to edit previous AM configurations in a textearea. Instead users will only be allowed to reset to a specific one with the same content. For this reason the textearea for old conf igurations is disabled and a different form action (not submit) is executed on the "reset config" button. The updateAlertManage rConfigAction is reset to its old functionality due to these changes. * Add id to AlertManagerCortexConfig type We'll need it to pass as a parameter to the new reset endpoint * Add new endpoint for resetting AM configs to an old version * Move the "Reset to selected configuration" button next to the drop-down * Add relative offset to configurations
This commit is contained in:
parent
85f738cdf9
commit
f27326f7d9
@ -34,6 +34,8 @@ export async function fetchAlertManagerConfig(alertManagerSourceName: string): P
|
||||
template_files: result.data.template_files ?? {},
|
||||
template_file_provenances: result.data.template_file_provenances ?? {},
|
||||
alertmanager_config: result.data.alertmanager_config ?? {},
|
||||
last_applied: result.data.last_applied,
|
||||
id: result.data.id,
|
||||
};
|
||||
} catch (e) {
|
||||
// if no config has been uploaded to grafana, it returns error instead of latest config
|
||||
|
@ -1,12 +1,16 @@
|
||||
import {
|
||||
AlertmanagerChoice,
|
||||
AlertManagerCortexConfig,
|
||||
ExternalAlertmanagerConfig,
|
||||
ExternalAlertmanagers,
|
||||
ExternalAlertmanagersResponse,
|
||||
} from '../../../../plugins/datasource/alertmanager/types';
|
||||
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
const LIMIT_TO_SUCCESSFULLY_APPLIED_AMS = 10;
|
||||
|
||||
export interface AlertmanagersChoiceResponse {
|
||||
alertmanagersChoice: AlertmanagerChoice;
|
||||
numExternalAlertmanagers: number;
|
||||
@ -33,5 +37,24 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
||||
query: (config) => ({ url: '/api/v1/ngalert/admin_config', method: 'POST', data: config }),
|
||||
invalidatesTags: ['AlertmanagerChoice'],
|
||||
}),
|
||||
|
||||
getValidAlertManagersConfig: build.query<AlertManagerCortexConfig[], void>({
|
||||
//this is only available for the "grafana" alert manager
|
||||
query: () => ({
|
||||
url: `/api/alertmanager/${getDatasourceAPIUid(
|
||||
GRAFANA_RULES_SOURCE_NAME
|
||||
)}/config/history?limit=${LIMIT_TO_SUCCESSFULLY_APPLIED_AMS}`,
|
||||
}),
|
||||
}),
|
||||
|
||||
resetAlertManagerConfigToOldVersion: build.mutation<{ message: string }, { id: number }>({
|
||||
//this is only available for the "grafana" alert manager
|
||||
query: (config) => ({
|
||||
url: `/api/alertmanager/${getDatasourceAPIUid(GRAFANA_RULES_SOURCE_NAME)}/config/history/${
|
||||
config.id
|
||||
}/_activate`,
|
||||
method: 'POST',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -100,9 +100,7 @@ describe('Admin config', () => {
|
||||
alertmanager_config: {},
|
||||
});
|
||||
mocks.api.deleteAlertManagerConfig.mockResolvedValue();
|
||||
|
||||
await renderAdminPage(dataSources.alertManager.name);
|
||||
|
||||
renderAdminPage(dataSources.alertManager.name);
|
||||
await userEvent.click(await ui.resetButton.find());
|
||||
await userEvent.click(ui.confirmButton.get());
|
||||
await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled());
|
||||
@ -128,7 +126,7 @@ describe('Admin config', () => {
|
||||
|
||||
mocks.api.fetchConfig.mockImplementation(() => Promise.resolve(savedConfig ?? defaultConfig));
|
||||
mocks.api.updateAlertManagerConfig.mockResolvedValue();
|
||||
await renderAdminPage(dataSources.alertManager.name);
|
||||
renderAdminPage(dataSources.alertManager.name);
|
||||
const input = await ui.configInput.find();
|
||||
expect(input.value).toEqual(JSON.stringify(defaultConfig, null, 2));
|
||||
await userEvent.clear(input);
|
||||
@ -147,7 +145,7 @@ describe('Admin config', () => {
|
||||
...someCloudAlertManagerStatus,
|
||||
config: someCloudAlertManagerConfig.alertmanager_config,
|
||||
});
|
||||
await renderAdminPage(dataSources.promAlertManager.name);
|
||||
renderAdminPage(dataSources.promAlertManager.name);
|
||||
|
||||
await ui.readOnlyConfig.find();
|
||||
expect(ui.configInput.query()).not.toBeInTheDocument();
|
||||
|
@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, useStyles2 } from '@grafana/ui';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName';
|
||||
@ -17,7 +17,10 @@ import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource }
|
||||
import { initialAsyncRequestState } from '../../utils/redux';
|
||||
import { AlertManagerPicker } from '../AlertManagerPicker';
|
||||
|
||||
interface FormValues {
|
||||
import AlertmanagerConfigSelector, { ValidAmConfigOption } from './AlertmanagerConfigSelector';
|
||||
import { ConfigEditor } from './ConfigEditor';
|
||||
|
||||
export interface FormValues {
|
||||
configJSON: string;
|
||||
}
|
||||
|
||||
@ -29,11 +32,14 @@ export default function AlertmanagerConfig(): JSX.Element {
|
||||
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
|
||||
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
|
||||
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||
|
||||
const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||
|
||||
const [selectedAmConfig, setSelectedAmConfig] = useState<ValidAmConfigOption | undefined>();
|
||||
|
||||
const {
|
||||
result: config,
|
||||
loading: isLoadingConfig,
|
||||
@ -60,6 +66,13 @@ export default function AlertmanagerConfig(): JSX.Element {
|
||||
[config]
|
||||
);
|
||||
|
||||
const defaultValidValues = useMemo(
|
||||
(): FormValues => ({
|
||||
configJSON: selectedAmConfig ? JSON.stringify(selectedAmConfig.value, null, 2) : '',
|
||||
}),
|
||||
[selectedAmConfig]
|
||||
);
|
||||
|
||||
const loading = isDeleting || isLoadingConfig || isSaving;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
@ -84,9 +97,25 @@ export default function AlertmanagerConfig(): JSX.Element {
|
||||
dataSources={alertManagers}
|
||||
/>
|
||||
{loadingError && !loading && (
|
||||
<Alert severity="error" title="Error loading Alertmanager configuration">
|
||||
{loadingError.message || 'Unknown error.'}
|
||||
</Alert>
|
||||
<>
|
||||
<Alert
|
||||
severity="error"
|
||||
title="Your Alertmanager configuration is incorrect. These are the details of the error:"
|
||||
>
|
||||
{loadingError.message || 'Unknown error.'}
|
||||
</Alert>
|
||||
|
||||
{alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && (
|
||||
<AlertmanagerConfigSelector
|
||||
onChange={setSelectedAmConfig}
|
||||
selectedAmConfig={selectedAmConfig}
|
||||
defaultValues={defaultValidValues}
|
||||
readOnly={true}
|
||||
loading={loading}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && (
|
||||
<Alert severity="info" title="Resetting Alertmanager configuration">
|
||||
@ -94,70 +123,17 @@ export default function AlertmanagerConfig(): JSX.Element {
|
||||
</Alert>
|
||||
)}
|
||||
{alertManagerSourceName && config && (
|
||||
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
|
||||
{({ register, errors }) => (
|
||||
<>
|
||||
{!readOnly && (
|
||||
<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 instanceof Error ? e.message : 'Invalid JSON.';
|
||||
}
|
||||
},
|
||||
})}
|
||||
id="configuration"
|
||||
rows={25}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{readOnly && (
|
||||
<Field label="Configuration">
|
||||
<pre data-testid="readonly-config">{defaultValues.configJSON}</pre>
|
||||
</Field>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<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>
|
||||
<ConfigEditor
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
readOnly={readOnly}
|
||||
loading={loading}
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
showConfirmDeleteAMConfig={showConfirmDeleteAMConfig}
|
||||
onReset={() => setShowConfirmDeleteAMConfig(true)}
|
||||
onConfirmReset={resetConfig}
|
||||
onDismiss={() => setShowConfirmDeleteAMConfig(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -0,0 +1,110 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { dateTime, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, Select, useStyles2 } from '@grafana/ui';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { FormValues } from './AlertmanagerConfig';
|
||||
import { ConfigEditor } from './ConfigEditor';
|
||||
|
||||
export interface ValidAmConfigOption {
|
||||
label?: string;
|
||||
value?: AlertManagerCortexConfig;
|
||||
}
|
||||
|
||||
interface AlertmanagerConfigSelectorProps {
|
||||
onChange: (selectedOption: ValidAmConfigOption) => void;
|
||||
selectedAmConfig?: ValidAmConfigOption;
|
||||
defaultValues: FormValues;
|
||||
onSubmit: (values: FormValues, oldConfig?: AlertManagerCortexConfig) => void;
|
||||
readOnly: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function AlertmanagerConfigSelector({
|
||||
onChange,
|
||||
selectedAmConfig,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
readOnly,
|
||||
loading,
|
||||
}: AlertmanagerConfigSelectorProps): JSX.Element {
|
||||
const { useGetValidAlertManagersConfigQuery, useResetAlertManagerConfigToOldVersionMutation } = alertmanagerApi;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { currentData: validAmConfigs, isLoading: isFetchingValidAmConfigs } = useGetValidAlertManagersConfigQuery();
|
||||
|
||||
const [resetAlertManagerConfigToOldVersion] = useResetAlertManagerConfigToOldVersionMutation();
|
||||
|
||||
const validAmConfigsOptions = useMemo(() => {
|
||||
if (!validAmConfigs?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const configs: ValidAmConfigOption[] = validAmConfigs.map((config) => {
|
||||
const date = new Date(config.last_applied!);
|
||||
return {
|
||||
label: config.last_applied
|
||||
? `Config from ${date.toLocaleString()} (${dateTime(date).locale('en').fromNow(true)} ago)`
|
||||
: 'Previous config',
|
||||
value: config,
|
||||
};
|
||||
});
|
||||
onChange(configs[0]);
|
||||
return configs;
|
||||
}, [validAmConfigs, onChange]);
|
||||
|
||||
const onResetClick = async () => {
|
||||
const id = selectedAmConfig?.value?.id;
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetAlertManagerConfigToOldVersion({ id });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isFetchingValidAmConfigs && validAmConfigs && validAmConfigs.length > 0 ? (
|
||||
<>
|
||||
<div>Select a previous working configuration until you fix this error:</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
<HorizontalGroup align="flex-start" spacing="md">
|
||||
<Select
|
||||
options={validAmConfigsOptions}
|
||||
value={selectedAmConfig}
|
||||
onChange={(value: SelectableValue) => {
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="primary" disabled={loading} onClick={onResetClick}>
|
||||
Reset to selected configuration
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
||||
<ConfigEditor
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
readOnly={readOnly}
|
||||
loading={loading}
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
margin-top: ${theme.spacing(2)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
});
|
@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } from '@grafana/ui';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { FormValues } from './AlertmanagerConfig';
|
||||
|
||||
interface ConfigEditorProps {
|
||||
defaultValues: { configJSON: string };
|
||||
readOnly: boolean;
|
||||
loading: boolean;
|
||||
alertManagerSourceName?: string;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
showConfirmDeleteAMConfig?: boolean;
|
||||
onReset?: () => void;
|
||||
onConfirmReset?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const ConfigEditor = ({
|
||||
defaultValues,
|
||||
readOnly,
|
||||
loading,
|
||||
alertManagerSourceName,
|
||||
showConfirmDeleteAMConfig,
|
||||
onSubmit,
|
||||
onReset,
|
||||
onConfirmReset,
|
||||
onDismiss,
|
||||
}: ConfigEditorProps) => {
|
||||
return (
|
||||
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
|
||||
{({ register, errors }) => (
|
||||
<>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<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 instanceof Error ? e.message : 'Invalid JSON.';
|
||||
}
|
||||
},
|
||||
})}
|
||||
id="configuration"
|
||||
rows={25}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<HorizontalGroup>
|
||||
<Button type="submit" variant="primary" disabled={loading}>
|
||||
Save
|
||||
</Button>
|
||||
{onReset && (
|
||||
<Button type="button" disabled={loading} variant="destructive" onClick={onReset}>
|
||||
Reset configuration
|
||||
</Button>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
)}
|
||||
{readOnly && (
|
||||
<Field label="Configuration">
|
||||
<pre data-testid="readonly-config">{defaultValues.configJSON}</pre>
|
||||
</Field>
|
||||
)}
|
||||
{Boolean(showConfirmDeleteAMConfig) && onConfirmReset && onDismiss && (
|
||||
<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={onConfirmReset}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
@ -162,6 +162,8 @@ export const fetchAlertManagerConfigAction = createAsyncThunk(
|
||||
alertmanager_config: status.config,
|
||||
template_files: {},
|
||||
template_file_provenances: result.template_file_provenances,
|
||||
last_applied: result.last_applied,
|
||||
id: result.id,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
@ -550,10 +552,10 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
|
||||
// TODO there must be a better way here than to dispatch another fetch as this causes re-rendering :(
|
||||
const latestConfig = await thunkAPI.dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)).unwrap();
|
||||
|
||||
if (
|
||||
!(isEmpty(latestConfig.alertmanager_config) && isEmpty(latestConfig.template_files)) &&
|
||||
JSON.stringify(latestConfig) !== JSON.stringify(oldConfig)
|
||||
) {
|
||||
const isLatestConfigEmpty = isEmpty(latestConfig.alertmanager_config) && isEmpty(latestConfig.template_files);
|
||||
const oldLastConfigsDiffer = JSON.stringify(latestConfig) !== JSON.stringify(oldConfig);
|
||||
|
||||
if (!isLatestConfigEmpty && oldLastConfigsDiffer) {
|
||||
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.'
|
||||
);
|
||||
|
@ -7,6 +7,8 @@ export type AlertManagerCortexConfig = {
|
||||
alertmanager_config: AlertmanagerConfig;
|
||||
/** { [name]: provenance } */
|
||||
template_file_provenances?: Record<string, string>;
|
||||
last_applied?: string;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
export type TLSConfig = {
|
||||
@ -155,6 +157,7 @@ export type AlertmanagerConfig = {
|
||||
mute_time_intervals?: MuteTimeInterval[];
|
||||
/** { [name]: provenance } */
|
||||
muteTimeProvenances?: Record<string, string>;
|
||||
last_applied?: boolean;
|
||||
};
|
||||
|
||||
export type Matcher = {
|
||||
|
Loading…
Reference in New Issue
Block a user