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:
Virginia Cepeda 2023-04-05 15:13:33 -03:00 committed by GitHub
parent 85f738cdf9
commit f27326f7d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 288 additions and 78 deletions

View File

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

View File

@ -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',
}),
}),
}),
});

View File

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

View File

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

View File

@ -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)};
`,
});

View File

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

View File

@ -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.'
);

View File

@ -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 = {