Alerting: Use time_intervals instead of the deprecated mute_time_intervals in a… (#83147)

* Use time_intervals instead of the deprecated mute_time_intervals in alert manager config

* don't send mute_time_intervals in the payload

* Add and update tests

* Fix usecase when having both fields in response from getting alert manager config

* Use mute_timings for grafana data source and both for cloud data source when deleting mute timing

* Use mute_timings for grafana data source and both for cloud data source when saving a new or existing alert rule

* Address first code review

* Address more review comments
This commit is contained in:
Sonia Aguilar 2024-02-28 15:21:00 +01:00 committed by GitHub
parent 90e7791086
commit ed3c36bb46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 279 additions and 35 deletions

View File

@ -1,4 +1,4 @@
import { render, waitFor, fireEvent, within } from '@testing-library/react';
import { fireEvent, render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
@ -10,7 +10,7 @@ import { AccessControlAction } from 'app/types';
import MuteTimings from './MuteTimings';
import { fetchAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks';
import { DataSourceType } from './utils/datasource';
jest.mock('./api/alertmanager');
@ -71,6 +71,21 @@ const muteTimeInterval: MuteTimeInterval = {
},
],
};
const muteTimeInterval2: MuteTimeInterval = {
name: 'default-mute2',
time_intervals: [
{
times: [
{
start_time: '12:00',
end_time: '24:00',
},
],
days_of_month: ['15', '-1'],
months: ['august:december', 'march'],
},
],
};
const defaultConfig: AlertManagerCortexConfig = {
alertmanager_config: {
@ -90,6 +105,44 @@ const defaultConfig: AlertManagerCortexConfig = {
},
template_files: {},
};
const defaultConfigWithNewTimeIntervalsField: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
route: {
receiver: 'default',
group_by: ['alertname'],
routes: [
{
matchers: ['env=prod', 'region!=EU'],
mute_time_intervals: [muteTimeInterval.name],
},
],
},
templates: [],
time_intervals: [muteTimeInterval],
},
template_files: {},
};
const defaultConfigWithBothTimeIntervalsField: AlertManagerCortexConfig = {
alertmanager_config: {
receivers: [{ name: 'default' }, { name: 'critical' }],
route: {
receiver: 'default',
group_by: ['alertname'],
routes: [
{
matchers: ['env=prod', 'region!=EU'],
mute_time_intervals: [muteTimeInterval.name],
},
],
},
templates: [],
time_intervals: [muteTimeInterval],
mute_time_intervals: [muteTimeInterval2],
},
template_files: {},
};
const resetMocks = () => {
jest.resetAllMocks();
@ -110,7 +163,7 @@ describe('Mute timings', () => {
grantUserPermissions(Object.values(AccessControlAction));
});
it('creates a new mute timing', async () => {
it('creates a new mute timing, with mute_time_intervals in config', async () => {
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
@ -125,10 +178,12 @@ describe('Mute timings', () => {
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...defaultConfig.alertmanager_config,
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
{
@ -151,6 +206,102 @@ describe('Mute timings', () => {
});
});
it('creates a new mute timing, with time_intervals in config', async () => {
mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
return Promise.resolve({
...defaultConfigWithNewTimeIntervalsField,
});
});
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
await userEvent.type(ui.nameField.get(), 'maintenance period');
await userEvent.type(ui.startsAt.get(), '22:00');
await userEvent.type(ui.endsAt.get(), '24:00');
await userEvent.type(ui.days.get(), '-1');
await userEvent.type(ui.months.get(), 'january, july');
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals: _, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
{
name: 'maintenance period',
time_intervals: [
{
days_of_month: ['-1'],
months: ['january', 'july'],
times: [
{
start_time: '22:00',
end_time: '24:00',
},
],
},
],
},
],
},
});
});
it('creates a new mute timing, with time_intervals and mute_time_intervals in config', async () => {
mocks.api.fetchAlertManagerConfig.mockImplementation(() => {
return Promise.resolve({
...defaultConfigWithBothTimeIntervalsField,
});
});
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
await userEvent.type(ui.nameField.get(), 'maintenance period');
await userEvent.type(ui.startsAt.get(), '22:00');
await userEvent.type(ui.endsAt.get(), '24:00');
await userEvent.type(ui.days.get(), '-1');
await userEvent.type(ui.months.get(), 'january, july');
fireEvent.submit(ui.form.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
const { mute_time_intervals, time_intervals, ...configWithoutMuteTimings } = defaultConfig.alertmanager_config;
expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', {
...defaultConfig,
alertmanager_config: {
...configWithoutMuteTimings,
mute_time_intervals: [
muteTimeInterval,
muteTimeInterval2,
{
name: 'maintenance period',
time_intervals: [
{
days_of_month: ['-1'],
months: ['january', 'july'],
times: [
{
start_time: '22:00',
end_time: '24:00',
},
],
},
],
},
],
},
});
});
it('prepopulates the form when editing a mute timing', async () => {
renderMuteTimings('/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`);

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom';
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
import { NavModelItem } from '@grafana/data';
import { Alert } from '@grafana/ui';
@ -21,8 +21,9 @@ const MuteTimings = () => {
const config = currentData?.alertmanager_config;
const getMuteTimingByName = useCallback(
(id: string): MuteTimeInterval | undefined => {
const timing = config?.mute_time_intervals?.find(({ name }: MuteTimeInterval) => name === id);
(id: string, fromTimeIntervals: boolean): MuteTimeInterval | undefined => {
const time_intervals = fromTimeIntervals ? config?.time_intervals ?? [] : config?.mute_time_intervals ?? [];
const timing = time_intervals.find(({ name }: MuteTimeInterval) => name === id);
if (timing) {
const provenance = config?.muteTimeProvenances?.[timing.name];
@ -53,13 +54,17 @@ const MuteTimings = () => {
<Route exact path="/alerting/routes/mute-timing/edit">
{() => {
if (queryParams['muteName']) {
const muteTiming = getMuteTimingByName(String(queryParams['muteName']));
const muteTimingInMuteTimings = getMuteTimingByName(String(queryParams['muteName']), false);
const muteTimingInTimeIntervals = getMuteTimingByName(String(queryParams['muteName']), true);
const inTimeIntervals = Boolean(muteTimingInTimeIntervals);
const muteTiming = inTimeIntervals ? muteTimingInTimeIntervals : muteTimingInMuteTimings;
const provenance = muteTiming?.provenance;
return (
<MuteTimingForm
loading={isLoading}
muteTiming={muteTiming}
fromLegacyTimeInterval={muteTimingInMuteTimings}
fromTimeIntervals={muteTimingInTimeIntervals}
showError={!muteTiming && !isLoading}
provenance={provenance}
/>

View File

@ -15,6 +15,7 @@ import { useGetContactPointsState } from './api/receiversApi';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
import { mergeTimeIntervals } from './components/mute-timings/util';
import {
NotificationPoliciesFilter,
findRoutesByMatchers,
@ -191,8 +192,9 @@ const AmRoutes = () => {
if (!selectedAlertmanager) {
return null;
}
const time_intervals = result?.alertmanager_config ? mergeTimeIntervals(result?.alertmanager_config) : [];
const numberOfMuteTimings = result?.alertmanager_config.mute_time_intervals?.length ?? 0;
const numberOfMuteTimings = time_intervals.length;
const haveData = result && !resultError && !resultLoading;
const isFetching = !result && resultLoading;
const haveError = resultError && !resultLoading;

View File

@ -12,6 +12,7 @@ import { useAlertmanager } from '../../state/AlertmanagerContext';
import { updateAlertManagerConfigAction } from '../../state/actions';
import { MuteTimingFields } from '../../types/mute-timing-form';
import { renameMuteTimings } from '../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { createMuteTiming, defaultTimeInterval } from '../../utils/mute-timings';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
@ -19,7 +20,8 @@ import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { MuteTimingTimeInterval } from './MuteTimingTimeInterval';
interface Props {
muteTiming?: MuteTimeInterval;
fromLegacyTimeInterval?: MuteTimeInterval; // mute time interval when comes from the old config , mute_time_intervals
fromTimeIntervals?: MuteTimeInterval; // mute time interval when comes from the new config , time_intervals. These two fields are mutually exclusive
showError?: boolean;
provenance?: string;
loading?: boolean;
@ -50,7 +52,26 @@ const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => {
};
};
const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) => {
const replaceMuteTiming = (
originalTimings: MuteTimeInterval[],
existingTiming: MuteTimeInterval | undefined,
newTiming: MuteTimeInterval,
addNew: boolean
) => {
// we only add new timing if addNew is true. Otherwise, we just remove the existing timing
const originalTimingsWithoutNew = existingTiming
? originalTimings?.filter(({ name }) => name !== existingTiming.name)
: originalTimings;
return addNew ? [...originalTimingsWithoutNew, newTiming] : [...originalTimingsWithoutNew];
};
const MuteTimingForm = ({
fromLegacyTimeInterval: fromMuteTimings,
fromTimeIntervals,
showError,
loading,
provenance,
}: Props) => {
const dispatch = useDispatch();
const { selectedAlertmanager } = useAlertmanager();
const styles = useStyles2(getStyles);
@ -60,6 +81,12 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) =
const { currentData: result } = useAlertmanagerConfig(selectedAlertmanager);
const config = result?.alertmanager_config;
const fromIntervals = Boolean(fromTimeIntervals);
const muteTiming = fromIntervals ? fromTimeIntervals : fromMuteTimings;
const originalMuteTimings = config?.mute_time_intervals ?? [];
const originalTimeIntervals = config?.time_intervals ?? [];
const defaultValues = useDefaultValues(muteTiming);
const formApi = useForm({ defaultValues });
@ -70,19 +97,44 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) =
const newMuteTiming = createMuteTiming(values);
const muteTimings = muteTiming
? config?.mute_time_intervals?.filter(({ name }) => name !== muteTiming.name)
: config?.mute_time_intervals;
const isGrafanaDataSource = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const isNewMuteTiming = fromTimeIntervals === undefined && fromMuteTimings === undefined;
// If is Grafana data source, we wil save mute timings in the alertmanager_config.mute_time_intervals
// Otherwise, we will save it on alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config
const newMutetimeIntervals = isGrafanaDataSource
? {
// for Grafana data source, we will save mute timings in the alertmanager_config.mute_time_intervals
mute_time_intervals: [
...replaceMuteTiming(originalTimeIntervals, fromTimeIntervals, newMuteTiming, false),
...replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true),
],
}
: {
// for non-Grafana data source, we will save mute timings in the alertmanager_config.time_intervals or alertmanager_config.mute_time_intervals depending on the original config
time_intervals: replaceMuteTiming(
originalTimeIntervals,
fromTimeIntervals,
newMuteTiming,
Boolean(fromTimeIntervals) || isNewMuteTiming
),
mute_time_intervals:
Boolean(fromMuteTimings) && !isNewMuteTiming
? replaceMuteTiming(originalMuteTimings, fromMuteTimings, newMuteTiming, true)
: undefined,
};
const { mute_time_intervals: _, time_intervals: __, ...configWithoutMuteTimings } = config ?? {};
const newConfig: AlertManagerCortexConfig = {
...result,
alertmanager_config: {
...config,
...configWithoutMuteTimings,
route:
muteTiming && newMuteTiming.name !== muteTiming.name
? renameMuteTimings(newMuteTiming.name, muteTiming.name, config?.route ?? {})
: config?.route,
mute_time_intervals: [...(muteTimings || []), newMuteTiming],
...newMutetimeIntervals,
},
};
@ -123,13 +175,8 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) =
<Input
{...formApi.register('name', {
required: true,
validate: (value) => {
if (!muteTiming) {
const existingMuteTiming = config?.mute_time_intervals?.find(({ name }) => value === name);
return existingMuteTiming ? `Mute timing already exists for "${value}"` : true;
}
return;
},
validate: (value) =>
validateMuteTiming(value, muteTiming, originalMuteTimings, originalTimeIntervals),
})}
className={styles.input}
data-testid={'mute-timing-name'}
@ -156,6 +203,22 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) =
);
};
function validateMuteTiming(
value: string,
muteTiming: MuteTimeInterval | undefined,
originalMuteTimings: MuteTimeInterval[],
originalTimeIntervals: MuteTimeInterval[]
) {
if (!muteTiming) {
const existingMuteTimingInMuteTimings = originalMuteTimings?.find(({ name }) => value === name);
const existingMuteTimingInTimeIntervals = originalTimeIntervals?.find(({ name }) => value === name);
return existingMuteTimingInMuteTimings || existingMuteTimingInTimeIntervals
? `Mute timing already exists for "${value}"`
: true;
}
return;
}
const getStyles = (theme: GrafanaTheme2) => ({
input: css`
width: 400px;

View File

@ -18,7 +18,7 @@ import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter';
import { renderTimeIntervals } from './util';
import { mergeTimeIntervals, renderTimeIntervals } from './util';
const ALL_MUTE_TIMINGS = Symbol('all mute timings');
@ -72,9 +72,9 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
const config = currentData?.alertmanager_config;
const [muteTimingName, setMuteTimingName] = useState<string>('');
const items = useMemo((): Array<DynamicTableItemProps<MuteTimeInterval>> => {
const muteTimings = config?.mute_time_intervals ?? [];
// merge both fields mute_time_intervals and time_intervals to support both old and new config
const muteTimings = config ? mergeTimeIntervals(config) : [];
const muteTimingsProvenances = config?.muteTimeProvenances ?? {};
return muteTimings
@ -88,7 +88,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
},
};
});
}, [config?.mute_time_intervals, config?.muteTimeProvenances, muteTimingNames]);
}, [muteTimingNames, config]);
const [_, allowedToCreateMuteTiming] = useAlertmanagerAbility(AlertmanagerAction.CreateMuteTiming);

View File

@ -1,7 +1,7 @@
import moment from 'moment';
import React from 'react';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import {
getDaysOfMonthString,
@ -18,6 +18,12 @@ const isvalidTimeFormat = (timeString: string): boolean => {
return timeString ? TIME_RANGE_REGEX.test(timeString) : true;
};
// merge both fields mute_time_intervals and time_intervals to support both old and new config
export const mergeTimeIntervals = (alertManagerConfig: AlertmanagerConfig) => {
return [...(alertManagerConfig.mute_time_intervals ?? []), ...(alertManagerConfig.time_intervals ?? [])];
};
// Usage
const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean => {
// empty time range is perfactly valid for a mute timing
if (!startTime && !endTime) {
@ -67,4 +73,4 @@ function renderTimeIntervals(muteTiming: MuteTimeInterval) {
});
}
export { isvalidTimeFormat, isValidStartAndEndTime, renderTimeIntervals };
export { isValidStartAndEndTime, isvalidTimeFormat, renderTimeIntervals };

View File

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { mergeTimeIntervals } from '../components/mute-timings/util';
import { useAlertmanager } from '../state/AlertmanagerContext';
import { timeIntervalToString } from '../utils/alertmanager';
@ -13,8 +14,9 @@ export function useMuteTimingOptions(): Array<SelectableValue<string>> {
const config = currentData?.alertmanager_config;
return useMemo(() => {
const time_intervals = config ? mergeTimeIntervals(config) : [];
const muteTimingsOptions: Array<SelectableValue<string>> =
config?.mute_time_intervals?.map((value) => ({
time_intervals?.map((value) => ({
value: value.name,
label: value.name,
description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '),

View File

@ -693,10 +693,24 @@ export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimin
alertmanagerApi.endpoints.getAlertmanagerConfiguration.initiate(alertManagerSourceName)
).unwrap();
const muteIntervals =
config?.alertmanager_config?.mute_time_intervals?.filter(({ name }) => name !== muteTimingName) ?? [];
const isGrafanaDatasource = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
const muteIntervalsFiltered =
(config?.alertmanager_config?.mute_time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? [];
const timeIntervalsFiltered =
(config?.alertmanager_config?.time_intervals ?? [])?.filter(({ name }) => name !== muteTimingName) ?? [];
const time_intervals_without_mute_to_save = isGrafanaDatasource
? {
mute_time_intervals: [...muteIntervalsFiltered, ...timeIntervalsFiltered],
}
: {
time_intervals: timeIntervalsFiltered,
mute_time_intervals: muteIntervalsFiltered,
};
if (config) {
const { mute_time_intervals: _, ...configWithoutMuteTimings } = config?.alertmanager_config ?? {};
withAppEvents(
dispatch(
updateAlertManagerConfigAction({
@ -705,11 +719,11 @@ export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimin
newConfig: {
...config,
alertmanager_config: {
...config.alertmanager_config,
...configWithoutMuteTimings,
route: config.alertmanager_config.route
? removeMuteTimingFromRoute(muteTimingName, config.alertmanager_config?.route)
: undefined,
mute_time_intervals: muteIntervals,
...time_intervals_without_mute_to_save,
},
},
})

View File

@ -157,6 +157,7 @@ export type AlertmanagerConfig = {
inhibit_rules?: InhibitRule[];
receivers?: Receiver[];
mute_time_intervals?: MuteTimeInterval[];
time_intervals?: MuteTimeInterval[];
/** { [name]: provenance } */
muteTimeProvenances?: Record<string, string>;
last_applied?: boolean;