Alerting: Adds support for timezones in mute timings (#68813)

This commit is contained in:
Gilles De Mey 2023-05-25 16:19:33 +02:00 committed by GitHub
parent 6393e2e713
commit 721d51a497
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1005 additions and 224 deletions

View File

@ -631,6 +631,7 @@ muteTimes:
- times:
- start_time: '06:00'
end_time: '23:59'
location: 'UTC'
weekdays: ['monday:wednesday', 'saturday', 'sunday']
months: ['1:3', 'may:august', 'december']
years: ['2020:2022', '2030']

View File

@ -1,4 +1,4 @@
import { render, waitFor, fireEvent } from '@testing-library/react';
import { render, waitFor, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
@ -109,7 +109,7 @@ describe('Mute timings', () => {
it('creates a new mute timing', async () => {
disableRBAC();
await renderMuteTimings();
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
@ -149,10 +149,8 @@ describe('Mute timings', () => {
});
});
it('prepoluates the form when editing a mute timing', async () => {
await renderMuteTimings(
'/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`
);
it('prepopulates the form when editing a mute timing', async () => {
renderMuteTimings('/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`);
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();
@ -161,12 +159,12 @@ describe('Mute timings', () => {
await userEvent.clear(ui.startsAt.getAll()?.[0]);
await userEvent.clear(ui.endsAt.getAll()?.[0]);
await userEvent.clear(ui.weekdays.get());
await userEvent.clear(ui.days.get());
await userEvent.clear(ui.months.get());
await userEvent.clear(ui.years.get());
await userEvent.type(ui.weekdays.get(), 'monday');
const monday = within(ui.weekdays.get()).getByText('Mon');
await userEvent.click(monday);
await userEvent.type(ui.days.get(), '-7:-1');
await userEvent.type(ui.months.get(), '3, 6, 9, 12');
await userEvent.type(ui.years.get(), '2021:2024');
@ -215,7 +213,7 @@ describe('Mute timings', () => {
});
it('form is invalid with duplicate mute timing name', async () => {
await renderMuteTimings();
renderMuteTimings();
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
await waitFor(() => expect(ui.nameField.get()).toBeInTheDocument());
@ -231,9 +229,7 @@ describe('Mute timings', () => {
});
it('replaces mute timings in routes when the mute timing name is changed', async () => {
await renderMuteTimings(
'/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`
);
renderMuteTimings('/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}`);
await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled());
expect(ui.nameField.get()).toBeInTheDocument();

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect } from 'react';
import { Route, Redirect, Switch } from 'react-router-dom';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { Alert } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
@ -56,8 +56,7 @@ const MuteTimings = () => {
return (
<>
{loading && <LoadingPlaceholder text="Loading mute timing" />}
{error && !loading && (
{error && !loading && !result && (
<Alert severity="error" title={`Error loading Alertmanager config for ${alertManagerSourceName}`}>
{error.message || 'Unknown error.'}
</Alert>
@ -65,7 +64,7 @@ const MuteTimings = () => {
{result && !error && (
<Switch>
<Route exact path="/alerting/routes/mute-timing/new">
<MuteTimingForm />
<MuteTimingForm loading={loading} />
</Route>
<Route exact path="/alerting/routes/mute-timing/edit">
{() => {
@ -73,7 +72,14 @@ const MuteTimings = () => {
const muteTiming = getMuteTimingByName(String(queryParams['muteName']));
const provenance = muteTiming?.provenance;
return <MuteTimingForm muteTiming={muteTiming} showError={!muteTiming} provenance={provenance} />;
return (
<MuteTimingForm
loading={loading}
muteTiming={muteTiming}
showError={!muteTiming && !loading}
provenance={provenance}
/>
);
}
return <Redirect to="/alerting/routes" />;
}}

View File

@ -1,9 +1,9 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import React, { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Alert, Button, Field, FieldSet, Input, LinkButton, useStyles2 } from '@grafana/ui';
import { Alert, Button, Field, FieldSet, Input, LinkButton, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import {
AlertmanagerConfig,
AlertManagerCortexConfig,
@ -30,47 +30,49 @@ interface Props {
muteTiming?: MuteTimeInterval;
showError?: boolean;
provenance?: string;
loading?: boolean;
}
const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => {
return useMemo(() => {
const defaultValues = {
name: '',
time_intervals: [defaultTimeInterval],
};
const defaultValues = {
name: '',
time_intervals: [defaultTimeInterval],
};
if (!muteTiming) {
return defaultValues;
}
if (!muteTiming) {
return defaultValues;
}
const intervals = muteTiming.time_intervals.map((interval) => ({
times: interval.times ?? defaultTimeInterval.times,
weekdays: interval?.weekdays?.join(', ') ?? defaultTimeInterval.weekdays,
days_of_month: interval?.days_of_month?.join(', ') ?? defaultTimeInterval.days_of_month,
months: interval?.months?.join(', ') ?? defaultTimeInterval.months,
years: interval?.years?.join(', ') ?? defaultTimeInterval.years,
}));
const intervals = muteTiming.time_intervals.map((interval) => ({
times: interval.times ?? defaultTimeInterval.times,
weekdays: interval.weekdays?.join(', ') ?? defaultTimeInterval.weekdays,
days_of_month: interval.days_of_month?.join(', ') ?? defaultTimeInterval.days_of_month,
months: interval.months?.join(', ') ?? defaultTimeInterval.months,
years: interval.years?.join(', ') ?? defaultTimeInterval.years,
location: interval.location ?? defaultTimeInterval.location,
}));
return {
name: muteTiming.name,
time_intervals: intervals,
};
}, [muteTiming]);
return {
name: muteTiming.name,
time_intervals: intervals,
};
};
const defaultPageNav: Partial<NavModelItem> = {
icon: 'sitemap',
};
const MuteTimingForm = ({ muteTiming, showError, provenance }: Props) => {
const MuteTimingForm = ({ muteTiming, showError, loading, provenance }: Props) => {
const dispatch = useDispatch();
const alertManagers = useAlertManagersByPermission('notification');
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(alertManagers);
const styles = useStyles2(getStyles);
const [updating, setUpdating] = useState(false);
const defaultAmCortexConfig = { alertmanager_config: {}, template_files: {} };
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
const { result = defaultAmCortexConfig, loading } =
const { result = defaultAmCortexConfig } =
(alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
const config: AlertmanagerConfig = result?.alertmanager_config ?? {};
@ -96,15 +98,22 @@ const MuteTimingForm = ({ muteTiming, showError, provenance }: Props) => {
},
};
dispatch(
const saveAction = dispatch(
updateAlertManagerConfigAction({
newConfig,
oldConfig: result,
alertManagerSourceName: alertManagerSourceName!,
successMessage: 'Mute timing saved',
redirectPath: '/alerting/routes/',
redirectSearch: 'tab=mute_timings',
})
);
setUpdating(true);
saveAction.unwrap().finally(() => {
setUpdating(false);
});
};
return (
@ -123,11 +132,12 @@ const MuteTimingForm = ({ muteTiming, showError, provenance }: Props) => {
dataSources={alertManagers}
/>
{provenance && <ProvisioningAlert resource={ProvisionedResource.MuteTiming} />}
{result && !loading && (
{loading && <LoadingPlaceholder text="Loading mute timing" />}
{showError && <Alert title="No matching mute timing found" />}
{result && !loading && !showError && (
<FormProvider {...formApi}>
<form onSubmit={formApi.handleSubmit(onSubmit)} data-testid="mute-timing-form">
{showError && <Alert title="No matching mute timing found" />}
<FieldSet label={'Create mute timing'} disabled={Boolean(provenance)}>
<FieldSet label={'Create mute timing'} disabled={Boolean(provenance) || updating}>
<Field
required
label="Name"
@ -143,7 +153,7 @@ const MuteTimingForm = ({ muteTiming, showError, provenance }: Props) => {
const existingMuteTiming = config?.mute_time_intervals?.find(({ name }) => value === name);
return existingMuteTiming ? `Mute timing already exists for "${value}"` : true;
}
return value.length > 0 || 'Name is required';
return;
},
})}
className={styles.input}
@ -151,13 +161,15 @@ const MuteTimingForm = ({ muteTiming, showError, provenance }: Props) => {
/>
</Field>
<MuteTimingTimeInterval />
<Button type="submit" className={styles.submitButton}>
<Button type="submit" className={styles.submitButton} disabled={updating}>
Save mute timing
</Button>
<LinkButton
type="button"
variant="secondary"
href={makeAMLink('/alerting/routes/', alertManagerSourceName)}
fill="outline"
href={makeAMLink('/alerting/routes/', alertManagerSourceName, { tab: 'mute_timings' })}
disabled={updating}
>
Cancel
</LinkButton>

View File

@ -1,18 +1,21 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext, useFieldArray } from 'react-hook-form';
import { css, cx } from '@emotion/css';
import { concat, uniq, upperFirst, without } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Input, Field, FieldSet, useStyles2 } from '@grafana/ui';
import { Stack } from '@grafana/experimental';
import { Button, Field, FieldSet, Icon, Input, useStyles2 } from '@grafana/ui';
import { MuteTimingFields } from '../../types/mute-timing-form';
import { DAYS_OF_THE_WEEK, MONTHS, validateArrayField, defaultTimeInterval } from '../../utils/mute-timings';
import { DAYS_OF_THE_WEEK, defaultTimeInterval, MONTHS, validateArrayField } from '../../utils/mute-timings';
import { MuteTimingTimeRange } from './MuteTimingTimeRange';
import { TimezoneSelect } from './timezones';
export const MuteTimingTimeInterval = () => {
const styles = useStyles2(getStyles);
const { formState, register } = useFormContext();
const { formState, register, setValue } = useFormContext();
const {
fields: timeIntervals,
append: addTimeInterval,
@ -22,7 +25,7 @@ export const MuteTimingTimeInterval = () => {
});
return (
<FieldSet className={styles.timeIntervalLegend} label="Time intervals">
<FieldSet label="Time intervals">
<>
<p>
A time interval is a definition for a moment in time. All fields are lists, and at least one list element must
@ -30,106 +33,114 @@ export const MuteTimingTimeInterval = () => {
instant of time to match a complete time interval, all fields must match. A mute timing can contain multiple
time intervals.
</p>
{timeIntervals.map((timeInterval, timeIntervalIndex) => {
const errors = formState.errors;
return (
<div key={timeInterval.id} className={styles.timeIntervalSection}>
<MuteTimingTimeRange intervalIndex={timeIntervalIndex} />
<Field
label="Days of the week"
error={errors.time_intervals?.[timeIntervalIndex]?.weekdays?.message ?? ''}
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.weekdays}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.weekdays`, {
validate: (value) =>
validateArrayField(
value,
(day) => DAYS_OF_THE_WEEK.includes(day.toLowerCase()),
'Invalid day of the week'
),
})}
className={styles.input}
data-testid="mute-timing-weekdays"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.weekdays}
placeholder="Example: monday, tuesday:thursday"
/>
</Field>
<Field
label="Days of the month"
description="The days of the month, 1-31, of a month. Negative values can be used to represent days which begin at the end of the month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.days_of_month}
error={errors.time_intervals?.[timeIntervalIndex]?.days_of_month?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.days_of_month`, {
validate: (value) =>
validateArrayField(
value,
(day) => {
const parsedDay = parseInt(day, 10);
return (parsedDay > -31 && parsedDay < 0) || (parsedDay > 0 && parsedDay < 32);
},
'Invalid day'
),
})}
className={styles.input}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.days_of_month}
placeholder="Example: 1, 14:16, -1"
data-testid="mute-timing-days"
/>
</Field>
<Field
label="Months"
description="The months of the year in either numerical or the full calendar month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.months}
error={errors.time_intervals?.[timeIntervalIndex]?.months?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.months`, {
validate: (value) =>
validateArrayField(
value,
(month) => MONTHS.includes(month) || (parseInt(month, 10) < 13 && parseInt(month, 10) > 0),
'Invalid month'
),
})}
className={styles.input}
placeholder="Example: 1:3, may:august, december"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.months}
data-testid="mute-timing-months"
/>
</Field>
<Field
label="Years"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.years}
error={errors.time_intervals?.[timeIntervalIndex]?.years?.message ?? ''}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.years`, {
validate: (value) => validateArrayField(value, (year) => /^\d{4}$/.test(year), 'Invalid year'),
})}
className={styles.input}
placeholder="Example: 2021:2022, 2030"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.years}
data-testid="mute-timing-years"
/>
</Field>
<Button
type="button"
variant="destructive"
icon="trash-alt"
onClick={() => removeTimeInterval(timeIntervalIndex)}
>
Remove time interval
</Button>
</div>
);
})}
<Stack direction="column" gap={2}>
{timeIntervals.map((timeInterval, timeIntervalIndex) => {
const errors = formState.errors;
// manually register the "location" field, react-hook-form doesn't handle nested field arrays well and will refuse to set
// the default value for the field when using "useFieldArray"
register(`time_intervals.${timeIntervalIndex}.location`);
return (
<div key={timeInterval.id} className={styles.timeIntervalSection}>
<MuteTimingTimeRange intervalIndex={timeIntervalIndex} />
<Field label="Location" invalid={Boolean(errors.location)} error={errors.location?.message}>
<TimezoneSelect
prefix={<Icon name="map-marker" />}
width={50}
onChange={(selectedTimezone) => {
setValue(`time_intervals.${timeIntervalIndex}.location`, selectedTimezone.value);
}}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={{ label: timeInterval.location, value: timeInterval.location }}
data-testid="mute-timing-location"
/>
</Field>
<Field label="Days of the week">
<DaysOfTheWeek
onChange={(daysOfWeek) => {
setValue(`time_intervals.${timeIntervalIndex}.weekdays`, daysOfWeek);
}}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.weekdays}
/>
</Field>
<Field
label="Days of the month"
description="The days of the month, 1-31, of a month. Negative values can be used to represent days which begin at the end of the month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.days_of_month}
error={errors.time_intervals?.[timeIntervalIndex]?.days_of_month?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.days_of_month`, {
validate: (value) =>
validateArrayField(
value,
(day) => {
const parsedDay = parseInt(day, 10);
return (parsedDay > -31 && parsedDay < 0) || (parsedDay > 0 && parsedDay < 32);
},
'Invalid day'
),
})}
width={50}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.days_of_month}
placeholder="Example: 1, 14:16, -1"
data-testid="mute-timing-days"
/>
</Field>
<Field
label="Months"
description="The months of the year in either numerical or the full calendar month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.months}
error={errors.time_intervals?.[timeIntervalIndex]?.months?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.months`, {
validate: (value) =>
validateArrayField(
value,
(month) => MONTHS.includes(month) || (parseInt(month, 10) < 13 && parseInt(month, 10) > 0),
'Invalid month'
),
})}
width={50}
placeholder="Example: 1:3, may:august, december"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.months}
data-testid="mute-timing-months"
/>
</Field>
<Field
label="Years"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.years}
error={errors.time_intervals?.[timeIntervalIndex]?.years?.message ?? ''}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.years`, {
validate: (value) => validateArrayField(value, (year) => /^\d{4}$/.test(year), 'Invalid year'),
})}
width={50}
placeholder="Example: 2021:2022, 2030"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.years}
data-testid="mute-timing-years"
/>
</Field>
<Button
type="button"
variant="destructive"
fill="outline"
icon="trash-alt"
onClick={() => removeTimeInterval(timeIntervalIndex)}
>
Remove time interval
</Button>
</div>
);
})}
</Stack>
<Button
type="button"
variant="secondary"
@ -146,21 +157,94 @@ export const MuteTimingTimeInterval = () => {
);
};
interface DaysOfTheWeekProps {
defaultValue?: string;
onChange: (input: string) => void;
}
const parseDays = (input: string): string[] => {
const parsedDays = input
.split(',')
.map((day) => day.trim())
// each "day" could still be a range of days, so we parse the range
.flatMap((day) => (day.includes(':') ? parseWeekdayRange(day) : day))
.map((day) => day.toLowerCase())
// remove invalid weekdays
.filter((day) => DAYS_OF_THE_WEEK.includes(day));
return uniq(parsedDays);
};
// parse monday:wednesday to ["monday", "tuesday", "wednesday"]
function parseWeekdayRange(input: string): string[] {
const [start = '', end = ''] = input.split(':');
const startIndex = DAYS_OF_THE_WEEK.indexOf(start);
const endIndex = DAYS_OF_THE_WEEK.indexOf(end);
return DAYS_OF_THE_WEEK.slice(startIndex, endIndex + 1);
}
const DaysOfTheWeek = ({ defaultValue = '', onChange }: DaysOfTheWeekProps) => {
const styles = useStyles2(getStyles);
const defaultValues = parseDays(defaultValue);
const [selectedDays, setSelectedDays] = useState<string[]>(defaultValues);
const toggleDay = (day: string) => {
selectedDays.includes(day)
? setSelectedDays((selectedDays) => without(selectedDays, day))
: setSelectedDays((selectedDays) => concat(selectedDays, day));
};
useEffect(() => {
onChange(selectedDays.join(', '));
}, [selectedDays, onChange]);
return (
<div data-testid="mute-timing-weekdays">
<Stack gap={1}>
{DAYS_OF_THE_WEEK.map((day) => {
const style = cx(styles.dayOfTheWeek, selectedDays.includes(day) && 'selected');
const abbreviated = day.slice(0, 3);
return (
<button type="button" key={day} className={style} onClick={() => toggleDay(day)}>
{upperFirst(abbreviated)}
</button>
);
})}
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
input: css`
width: 400px;
`,
timeIntervalLegend: css`
legend {
font-size: 1.25rem;
}
`,
timeIntervalSection: css`
background-color: ${theme.colors.background.secondary};
padding: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(1)};
padding: ${theme.spacing(2)};
`,
removeTimeIntervalButton: css`
margin-top: ${theme.spacing(1)};
margin-top: ${theme.spacing(2)};
`,
dayOfTheWeek: css`
cursor: pointer;
user-select: none;
padding: ${theme.spacing(1)} ${theme.spacing(3)};
border: solid 1px ${theme.colors.border.medium};
background: none;
border-radius: ${theme.shape.borderRadius()};
color: ${theme.colors.text.secondary};
&.selected {
font-weight: ${theme.typography.fontWeightBold};
color: ${theme.colors.primary.text};
border-color: ${theme.colors.primary.border};
background: ${theme.colors.primary.transparent};
}
`,
});

View File

@ -3,17 +3,21 @@ import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, InlineFieldRow, InlineField, Input, Button, IconButton, useStyles2 } from '@grafana/ui';
import { Button, Field, Icon, IconButton, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui';
import { MuteTimingFields } from '../../types/mute-timing-form';
import { isValidStartAndEndTime, isvalidTimeFormat } from './util';
interface Props {
intervalIndex: number;
}
const INVALID_FORMAT_MESSAGE = 'Times must be between 00:00 and 24:00 UTC';
export const MuteTimingTimeRange = ({ intervalIndex }: Props) => {
const styles = useStyles2(getStyles);
const { register, formState } = useFormContext<MuteTimingFields>();
const { register, formState, getValues } = useFormContext<MuteTimingFields>();
const {
fields: timeRanges,
@ -23,18 +27,6 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => {
name: `time_intervals.${intervalIndex}.times`,
});
const validateTime = (timeString: string) => {
if (!timeString) {
return true;
}
const [hour, minutes] = timeString.split(':').map((x) => parseInt(x, 10));
const isHourValid = hour >= 0 && hour < 25;
const isMinuteValid = minutes > -1 && minutes < 60;
const isTimeValid = hour === 24 ? minutes === 0 : isHourValid && isMinuteValid;
return isTimeValid || 'Time is invalid';
};
const formErrors = formState.errors.time_intervals?.[intervalIndex];
const timeRangeInvalid = formErrors?.times?.some((value) => value?.start_time || value?.end_time) ?? false;
@ -45,34 +37,85 @@ export const MuteTimingTimeRange = ({ intervalIndex }: Props) => {
label="Time range"
description="The time inclusive of the starting time and exclusive of the end time in UTC"
invalid={timeRangeInvalid}
error={timeRangeInvalid ? 'Times must be between 00:00 and 24:00 UTC' : ''}
>
<>
{timeRanges.map((timeRange, index) => {
const timeRangeErrors = formErrors?.times?.[index];
const startTimeKey = `time_intervals.${intervalIndex}.times.${index}.start_time`;
const endTimeKey = `time_intervals.${intervalIndex}.times.${index}.end_time`;
const getStartAndEndTime = (): [string | undefined, string | undefined] => {
// @ts-ignore react-hook-form doesn't handle nested field arrays well
const startTime: string = getValues(startTimeKey);
// @ts-ignore react-hook-form doesn't handle nested field arrays well
const endTime: string = getValues(endTimeKey);
return [startTime, endTime];
};
return (
<div className={styles.timeRange} key={timeRange.id}>
<InlineFieldRow>
<InlineField label="Start time" invalid={!!formErrors?.times?.[index]?.start_time}>
<InlineField
label="Start time"
invalid={Boolean(timeRangeErrors?.start_time)}
error={timeRangeErrors?.start_time?.message}
>
<Input
{...register(`time_intervals.${intervalIndex}.times.${index}.start_time`, {
validate: validateTime,
// @ts-ignore
{...register(startTimeKey, {
validate: (input: string) => {
const validFormat = isvalidTimeFormat(input);
if (!validFormat) {
return INVALID_FORMAT_MESSAGE;
}
const [startTime, endTime] = getStartAndEndTime();
if (isValidStartAndEndTime(startTime, endTime)) {
return;
} else {
return 'Start time must be before end time';
}
},
})}
className={styles.timeRangeInput}
maxLength={5}
suffix={<Icon name="clock-nine" />}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeRange.start_time}
placeholder="HH:MM"
placeholder="HH:mm"
data-testid="mute-timing-starts-at"
/>
</InlineField>
<InlineField label="End time" invalid={!!formErrors?.times?.[index]?.end_time}>
<InlineField
label="End time"
invalid={Boolean(timeRangeErrors?.end_time)}
error={timeRangeErrors?.end_time?.message}
>
<Input
{...register(`time_intervals.${intervalIndex}.times.${index}.end_time`, {
validate: validateTime,
validate: (input: string) => {
const validFormat = isvalidTimeFormat(input);
if (!validFormat) {
return INVALID_FORMAT_MESSAGE;
}
const [startTime, endTime] = getStartAndEndTime();
if (isValidStartAndEndTime(startTime, endTime)) {
return;
} else {
return 'End time must be after start time';
}
},
})}
className={styles.timeRangeInput}
maxLength={5}
suffix={<Icon name="clock-nine" />}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeRange.end_time}
placeholder="HH:MM"
placeholder="HH:mm"
data-testid="mute-timing-ends-at"
/>
</InlineField>
@ -113,7 +156,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-bottom: ${theme.spacing(1)};
`,
timeRangeInput: css`
width: 120px;
width: 90px;
`,
deleteTimeRange: css`
margin: ${theme.spacing(1)} 0 0 ${theme.spacing(0.5)};

View File

@ -5,20 +5,13 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { IconButton, LinkButton, Link, useStyles2, ConfirmModal } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig, MuteTimeInterval, TimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { Authorize } from '../../components/Authorize';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { deleteMuteTimingAction } from '../../state/actions';
import { getNotificationsPermissions } from '../../utils/access-control';
import {
getTimeString,
getWeekdayString,
getDaysOfMonthString,
getMonthsString,
getYearsString,
} from '../../utils/alertmanager';
import { makeAMLink } from '../../utils/misc';
import { AsyncRequestState, initialAsyncRequestState } from '../../utils/redux';
import { DynamicTable, DynamicTableItemProps, DynamicTableColumnProps } from '../DynamicTable';
@ -26,6 +19,8 @@ import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { renderTimeIntervals } from './util';
interface Props {
alertManagerSourceName: string;
muteTimingNames?: string[];
@ -97,7 +92,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
showButton={contextSrv.hasPermission(permissions.create)}
/>
) : (
<p>No mute timings configured</p>
<EmptyAreaWithCTA text="No mute timings configured" buttonLabel={''} showButton={false} />
)}
{!hideActions && (
<ConfirmModal
@ -137,7 +132,9 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
{
id: 'timeRange',
label: 'Time range',
renderCell: ({ data }) => renderTimeIntervals(data.time_intervals),
renderCell: ({ data }) => {
return renderTimeIntervals(data);
},
},
];
if (showActions) {
@ -186,26 +183,6 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
}, [alertManagerSourceName, setMuteTimingName, showActions, permissions]);
}
function renderTimeIntervals(timeIntervals: TimeInterval[]) {
return timeIntervals.map((interval, index) => {
const { times, weekdays, days_of_month, months, years } = interval;
const timeString = getTimeString(times);
const weekdayString = getWeekdayString(weekdays);
const daysString = getDaysOfMonthString(days_of_month);
const monthsString = getMonthsString(months);
const yearsString = getYearsString(years);
return (
<React.Fragment key={JSON.stringify(interval) + index}>
{`${timeString} ${weekdayString}`}
<br />
{[daysString, monthsString, yearsString].join(' | ')}
<br />
</React.Fragment>
);
});
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: flex;

View File

@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renderTimeIntervals should render empty time interval 1`] = `[]`;
exports[`renderTimeIntervals should render time interval with kitchen sink 1`] = `
[
<React.Fragment>
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun
<br />
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021
<br />
</React.Fragment>,
<React.Fragment>
Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun
<br />
Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021
<br />
</React.Fragment>,
]
`;
exports[`renderTimeIntervals should render time interval with time range 1`] = `
[
<React.Fragment>
Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All
<br />
Days of the month: All | Months: All | Years: All
<br />
</React.Fragment>,
]
`;
exports[`renderTimeIntervals should render time interval with weekdays 1`] = `
[
<React.Fragment>
Times: All Weekdays: Mon, Tue-Thu, Sun
<br />
Days of the month: All | Months: All | Years: All
<br />
</React.Fragment>,
]
`;

View File

@ -0,0 +1,460 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { Select, SelectCommonProps } from '@grafana/ui';
const TIMEZONES = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',
'Africa/Algiers',
'Africa/Asmara',
'Africa/Bamako',
'Africa/Bangui',
'Africa/Banjul',
'Africa/Bissau',
'Africa/Blantyre',
'Africa/Brazzaville',
'Africa/Bujumbura',
'Africa/Cairo',
'Africa/Casablanca',
'Africa/Ceuta',
'Africa/Conakry',
'Africa/Dakar',
'Africa/Dar_es_Salaam',
'Africa/Djibouti',
'Africa/Douala',
'Africa/El_Aaiun',
'Africa/Freetown',
'Africa/Gaborone',
'Africa/Harare',
'Africa/Johannesburg',
'Africa/Juba',
'Africa/Kampala',
'Africa/Khartoum',
'Africa/Kigali',
'Africa/Kinshasa',
'Africa/Lagos',
'Africa/Libreville',
'Africa/Lome',
'Africa/Luanda',
'Africa/Lubumbashi',
'Africa/Lusaka',
'Africa/Malabo',
'Africa/Maputo',
'Africa/Maseru',
'Africa/Mbabane',
'Africa/Mogadishu',
'Africa/Monrovia',
'Africa/Nairobi',
'Africa/Ndjamena',
'Africa/Niamey',
'Africa/Nouakchott',
'Africa/Ouagadougou',
'Africa/Porto-Novo',
'Africa/Sao_Tome',
'Africa/Tripoli',
'Africa/Tunis',
'Africa/Windhoek',
'America/Adak',
'America/Anchorage',
'America/Anguilla',
'America/Antigua',
'America/Araguaina',
'America/Argentina/Buenos_Aires',
'America/Argentina/Catamarca',
'America/Argentina/Cordoba',
'America/Argentina/Jujuy',
'America/Argentina/La_Rioja',
'America/Argentina/Mendoza',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Salta',
'America/Argentina/San_Juan',
'America/Argentina/San_Luis',
'America/Argentina/Tucuman',
'America/Argentina/Ushuaia',
'America/Aruba',
'America/Asuncion',
'America/Atikokan',
'America/Bahia',
'America/Bahia_Banderas',
'America/Barbados',
'America/Belem',
'America/Belize',
'America/Blanc-Sablon',
'America/Boa_Vista',
'America/Bogota',
'America/Boise',
'America/Cambridge_Bay',
'America/Campo_Grande',
'America/Cancun',
'America/Caracas',
'America/Cayenne',
'America/Cayman',
'America/Chicago',
'America/Chihuahua',
'America/Ciudad_Juarez',
'America/Costa_Rica',
'America/Creston',
'America/Cuiaba',
'America/Curacao',
'America/Danmarkshavn',
'America/Dawson',
'America/Dawson_Creek',
'America/Denver',
'America/Detroit',
'America/Dominica',
'America/Edmonton',
'America/Eirunepe',
'America/El_Salvador',
'America/Fort_Nelson',
'America/Fortaleza',
'America/Glace_Bay',
'America/Godthab',
'America/Goose_Bay',
'America/Grand_Turk',
'America/Grenada',
'America/Guadeloupe',
'America/Guatemala',
'America/Guayaquil',
'America/Guyana',
'America/Halifax',
'America/Havana',
'America/Hermosillo',
'America/Indiana/Indianapolis',
'America/Indiana/Knox',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Tell_City',
'America/Indiana/Vevay',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Inuvik',
'America/Iqaluit',
'America/Jamaica',
'America/Juneau',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Kralendijk',
'America/La_Paz',
'America/Lima',
'America/Los_Angeles',
'America/Lower_Princes',
'America/Maceio',
'America/Managua',
'America/Manaus',
'America/Marigot',
'America/Martinique',
'America/Matamoros',
'America/Mazatlan',
'America/Menominee',
'America/Merida',
'America/Metlakatla',
'America/Mexico_City',
'America/Miquelon',
'America/Moncton',
'America/Monterrey',
'America/Montevideo',
'America/Montreal',
'America/Montserrat',
'America/Nassau',
'America/New_York',
'America/Nipigon',
'America/Nome',
'America/Noronha',
'America/North_Dakota/Beulah',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/Nuuk',
'America/Ojinaga',
'America/Panama',
'America/Pangnirtung',
'America/Paramaribo',
'America/Phoenix',
'America/Port-au-Prince',
'America/Port_of_Spain',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rainy_River',
'America/Rankin_Inlet',
'America/Recife',
'America/Regina',
'America/Resolute',
'America/Rio_Branco',
'America/Santa_Isabel',
'America/Santarem',
'America/Santiago',
'America/Santo_Domingo',
'America/Sao_Paulo',
'America/Scoresbysund',
'America/Shiprock',
'America/Sitka',
'America/St_Barthelemy',
'America/St_Johns',
'America/St_Kitts',
'America/St_Lucia',
'America/St_Thomas',
'America/St_Vincent',
'America/Swift_Current',
'America/Tegucigalpa',
'America/Thule',
'America/Thunder_Bay',
'America/Tijuana',
'America/Toronto',
'America/Tortola',
'America/Vancouver',
'America/Whitehorse',
'America/Winnipeg',
'America/Yakutat',
'America/Yellowknife',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Macquarie',
'Antarctica/Mawson',
'Antarctica/McMurdo',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/South_Pole',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'Arctic/Longyearbyen',
'Asia/Aden',
'Asia/Almaty',
'Asia/Amman',
'Asia/Anadyr',
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
'Asia/Bangkok',
'Asia/Barnaul',
'Asia/Beirut',
'Asia/Bishkek',
'Asia/Brunei',
'Asia/Calcutta',
'Asia/Chita',
'Asia/Choibalsan',
'Asia/Chongqing',
'Asia/Colombo',
'Asia/Damascus',
'Asia/Dhaka',
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Harbin',
'Asia/Hebron',
'Asia/Ho_Chi_Minh',
'Asia/Hong_Kong',
'Asia/Hovd',
'Asia/Irkutsk',
'Asia/Jakarta',
'Asia/Jayapura',
'Asia/Jerusalem',
'Asia/Kabul',
'Asia/Kamchatka',
'Asia/Karachi',
'Asia/Kashgar',
'Asia/Kathmandu',
'Asia/Katmandu',
'Asia/Khandyga',
'Asia/Krasnoyarsk',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Asia/Kuwait',
'Asia/Macau',
'Asia/Magadan',
'Asia/Makassar',
'Asia/Manila',
'Asia/Muscat',
'Asia/Nicosia',
'Asia/Novokuznetsk',
'Asia/Novosibirsk',
'Asia/Omsk',
'Asia/Oral',
'Asia/Phnom_Penh',
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
'Asia/Sakhalin',
'Asia/Samarkand',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Srednekolymsk',
'Asia/Taipei',
'Asia/Tashkent',
'Asia/Tbilisi',
'Asia/Tehran',
'Asia/Thimphu',
'Asia/Tokyo',
'Asia/Tomsk',
'Asia/Ulaanbaatar',
'Asia/Urumqi',
'Asia/Ust-Nera',
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yangon',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
'Atlantic/Bermuda',
'Atlantic/Canary',
'Atlantic/Cape_Verde',
'Atlantic/Faroe',
'Atlantic/Madeira',
'Atlantic/Reykjavik',
'Atlantic/South_Georgia',
'Atlantic/St_Helena',
'Atlantic/Stanley',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Broken_Hill',
'Australia/Currie',
'Australia/Darwin',
'Australia/Eucla',
'Australia/Hobart',
'Australia/Lindeman',
'Australia/Lord_Howe',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Europe/Amsterdam',
'Europe/Andorra',
'Europe/Astrakhan',
'Europe/Athens',
'Europe/Belgrade',
'Europe/Berlin',
'Europe/Bratislava',
'Europe/Brussels',
'Europe/Bucharest',
'Europe/Budapest',
'Europe/Busingen',
'Europe/Chisinau',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Gibraltar',
'Europe/Guernsey',
'Europe/Helsinki',
'Europe/Isle_of_Man',
'Europe/Istanbul',
'Europe/Jersey',
'Europe/Kaliningrad',
'Europe/Kiev',
'Europe/Kirov',
'Europe/Kyiv',
'Europe/Lisbon',
'Europe/Ljubljana',
'Europe/London',
'Europe/Luxembourg',
'Europe/Madrid',
'Europe/Malta',
'Europe/Mariehamn',
'Europe/Minsk',
'Europe/Monaco',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Podgorica',
'Europe/Prague',
'Europe/Riga',
'Europe/Rome',
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
'Europe/Stockholm',
'Europe/Tallinn',
'Europe/Tirane',
'Europe/Ulyanovsk',
'Europe/Uzhgorod',
'Europe/Vaduz',
'Europe/Vatican',
'Europe/Vienna',
'Europe/Vilnius',
'Europe/Volgograd',
'Europe/Warsaw',
'Europe/Zagreb',
'Europe/Zaporozhye',
'Europe/Zurich',
'GMT',
'Indian/Antananarivo',
'Indian/Chagos',
'Indian/Christmas',
'Indian/Cocos',
'Indian/Comoro',
'Indian/Kerguelen',
'Indian/Mahe',
'Indian/Maldives',
'Indian/Mauritius',
'Indian/Mayotte',
'Indian/Reunion',
'Local', // this is the local timezone of the machine
'Pacific/Apia',
'Pacific/Auckland',
'Pacific/Bougainville',
'Pacific/Chatham',
'Pacific/Chuuk',
'Pacific/Easter',
'Pacific/Efate',
'Pacific/Enderbury',
'Pacific/Fakaofo',
'Pacific/Fiji',
'Pacific/Funafuti',
'Pacific/Galapagos',
'Pacific/Gambier',
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Johnston',
'Pacific/Kanton',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',
'Pacific/Majuro',
'Pacific/Marquesas',
'Pacific/Midway',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Norfolk',
'Pacific/Noumea',
'Pacific/Pago_Pago',
'Pacific/Palau',
'Pacific/Pitcairn',
'Pacific/Pohnpei',
'Pacific/Ponape',
'Pacific/Port_Moresby',
'Pacific/Rarotonga',
'Pacific/Saipan',
'Pacific/Tahiti',
'Pacific/Tarawa',
'Pacific/Tongatapu',
'Pacific/Truk',
'Pacific/Wake',
'Pacific/Wallis',
'UTC',
];
export const TimezoneSelect = (options: SelectCommonProps<string>) => {
const timezoneOptions: Array<SelectableValue<string>> = TIMEZONES.map((tz) => ({
label: tz,
value: tz,
}));
return <Select<string> {...options} options={timezoneOptions} />;
};

View File

@ -0,0 +1,76 @@
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { renderTimeIntervals } from './util';
describe('renderTimeIntervals', () => {
it('should render empty time interval', () => {
const muteTiming: MuteTimeInterval = {
name: 'test',
time_intervals: [],
};
expect(renderTimeIntervals(muteTiming)).toMatchSnapshot();
});
it('should render time interval with time range', () => {
const muteTiming: MuteTimeInterval = {
name: 'test',
time_intervals: [
{
times: [
{
start_time: '12:00',
end_time: '13:00',
},
{
start_time: '14:00',
end_time: '15:00',
},
],
},
],
};
expect(renderTimeIntervals(muteTiming)).toMatchSnapshot();
});
it('should render time interval with weekdays', () => {
const muteTiming: MuteTimeInterval = {
name: 'test',
time_intervals: [
{
weekdays: ['monday', 'tuesday:thursday', 'sunday'],
},
],
};
expect(renderTimeIntervals(muteTiming)).toMatchSnapshot();
});
it('should render time interval with kitchen sink', () => {
const interval = {
weekdays: ['monday', 'tuesday:thursday', 'sunday'],
times: [
{
start_time: '12:00',
end_time: '13:00',
},
{
start_time: '14:00',
end_time: '15:00',
},
],
days_of_month: ['1', '2:4', '31'],
location: 'Europe/Berlin',
months: ['january', 'february:march', 'december'],
years: ['2019', '2020:2021'],
};
const muteTiming: MuteTimeInterval = {
name: 'test',
time_intervals: [interval, interval],
};
expect(renderTimeIntervals(muteTiming)).toMatchSnapshot();
});
});

View File

@ -0,0 +1,70 @@
import moment from 'moment';
import React from 'react';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import {
getDaysOfMonthString,
getMonthsString,
getTimeString,
getWeekdayString,
getYearsString,
} from '../../utils/alertmanager';
// https://github.com/prometheus/alertmanager/blob/9de8ef36755298a68b6ab20244d4369d38bdea99/timeinterval/timeinterval.go#L443
const TIME_RANGE_REGEX = /^((([01][0-9])|(2[0-3])):[0-5][0-9])$|(^24:00$)/;
const isvalidTimeFormat = (timeString: string): boolean => {
return timeString ? TIME_RANGE_REGEX.test(timeString) : true;
};
const isValidStartAndEndTime = (startTime?: string, endTime?: string): boolean => {
// empty time range is perfactly valid for a mute timing
if (!startTime && !endTime) {
return true;
}
if ((!startTime && endTime) || (startTime && !endTime)) {
return false;
}
const timeUnit = 'HH:mm';
// @ts-ignore typescript types here incorrect, sigh
const startDate = moment().startOf('day').add(startTime, timeUnit);
// @ts-ignore typescript types here incorrect, sigh
const endDate = moment().startOf('day').add(endTime, timeUnit);
if (startTime && endTime && startDate.isBefore(endDate)) {
return true;
}
if (startTime && endTime && endDate.isAfter(startDate)) {
return true;
}
return false;
};
function renderTimeIntervals(muteTiming: MuteTimeInterval) {
const timeIntervals = muteTiming.time_intervals;
return timeIntervals.map((interval, index) => {
const { times, weekdays, days_of_month, months, years, location } = interval;
const timeString = getTimeString(times, location);
const weekdayString = getWeekdayString(weekdays);
const daysString = getDaysOfMonthString(days_of_month);
const monthsString = getMonthsString(months);
const yearsString = getYearsString(years);
return (
<React.Fragment key={JSON.stringify(interval) + index}>
{`${timeString} ${weekdayString}`}
<br />
{[daysString, monthsString, yearsString].join(' | ')}
<br />
</React.Fragment>
);
});
}
export { isvalidTimeFormat, isValidStartAndEndTime, renderTimeIntervals };

View File

@ -554,12 +554,16 @@ interface UpdateAlertManagerConfigActionOptions {
newConfig: AlertManagerCortexConfig;
successMessage?: string; // show toast on success
redirectPath?: string; // where to redirect on success
redirectSearch?: string; // additional redirect query params
refetch?: boolean; // refetch config on success
}
export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlertManagerConfigActionOptions, {}>(
'unifiedalerting/updateAMConfig',
({ alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath, refetch }, thunkAPI): Promise<void> =>
(
{ alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath, redirectSearch, refetch },
thunkAPI
): Promise<void> =>
withAppEvents(
withSerializedError(
(async () => {
@ -579,7 +583,8 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
await thunkAPI.dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
}
if (redirectPath) {
locationService.push(makeAMLink(redirectPath, alertManagerSourceName));
const options = new URLSearchParams(redirectSearch ?? '');
locationService.push(makeAMLink(redirectPath, alertManagerSourceName, options));
}
})()
),

View File

@ -11,4 +11,5 @@ export type MuteTimingIntervalFields = {
days_of_month: string;
months: string;
years: string;
location?: string;
};

View File

@ -233,8 +233,8 @@ export function getAlertmanagerByUid(uid?: string) {
}
export function timeIntervalToString(timeInterval: TimeInterval): string {
const { times, weekdays, days_of_month, months, years } = timeInterval;
const timeString = getTimeString(times);
const { times, weekdays, days_of_month, months, years, location } = timeInterval;
const timeString = getTimeString(times, location);
const weekdayString = getWeekdayString(weekdays);
const daysString = getDaysOfMonthString(days_of_month);
const monthsString = getMonthsString(months);
@ -243,10 +243,12 @@ export function timeIntervalToString(timeInterval: TimeInterval): string {
return [timeString, weekdayString, daysString, monthsString, yearsString].join(', ');
}
export function getTimeString(times?: TimeRange[]): string {
export function getTimeString(times?: TimeRange[], location?: string): string {
return (
'Times: ' +
(times ? times?.map(({ start_time, end_time }) => `${start_time} - ${end_time} UTC`).join(' and ') : 'All')
(times
? times?.map(({ start_time, end_time }) => `${start_time} - ${end_time} [${location ?? 'UTC'}]`).join(' and ')
: 'All')
);
}

View File

@ -92,8 +92,10 @@ export function recordToArray(record: Record<string, string>): Array<{ key: stri
return Object.entries(record).map(([key, value]) => ({ key, value }));
}
export function makeAMLink(path: string, alertManagerName?: string, options?: Record<string, string>): string {
type URLParamsLike = ConstructorParameters<typeof URLSearchParams>[0];
export function makeAMLink(path: string, alertManagerName?: string, options?: URLParamsLike): string {
const search = new URLSearchParams(options);
if (alertManagerName) {
search.append(ALERTMANAGER_NAME_QUERY_KEY, alertManagerName);
}

View File

@ -27,6 +27,7 @@ export const defaultTimeInterval: MuteTimingIntervalFields = {
days_of_month: '',
months: '',
years: '',
location: '',
};
export const validateArrayField = (value: string, validateValue: (input: string) => boolean, invalidText: string) => {
@ -48,13 +49,14 @@ const convertStringToArray = (str: string) => {
export const createMuteTiming = (fields: MuteTimingFields): MuteTimeInterval => {
const timeIntervals: TimeInterval[] = fields.time_intervals.map(
({ times, weekdays, days_of_month, months, years }) => {
({ times, weekdays, days_of_month, months, years, location }) => {
const interval = {
times: times.filter(({ start_time, end_time }) => !!start_time && !!end_time),
weekdays: convertStringToArray(weekdays)?.map((v) => v.toLowerCase()),
days_of_month: convertStringToArray(days_of_month),
months: convertStringToArray(months),
years: convertStringToArray(years),
location: location ? location : undefined,
};
return omitBy(interval, isUndefined);

View File

@ -315,6 +315,8 @@ export interface TimeInterval {
days_of_month?: string[];
months?: string[];
years?: string[];
/** IANA TZ identifier like "Europe/Brussels", also supports "Local" or "UTC" */
location?: string;
}
export type MuteTimeInterval = {