mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Adds support for timezones in mute timings (#68813)
This commit is contained in:
parent
6393e2e713
commit
721d51a497
@ -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']
|
||||
|
@ -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();
|
||||
|
@ -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" />;
|
||||
}}
|
||||
|
@ -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>
|
||||
|
@ -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};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -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)};
|
||||
|
@ -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;
|
||||
|
@ -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>,
|
||||
]
|
||||
`;
|
@ -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} />;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 };
|
@ -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));
|
||||
}
|
||||
})()
|
||||
),
|
||||
|
@ -11,4 +11,5 @@ export type MuteTimingIntervalFields = {
|
||||
days_of_month: string;
|
||||
months: string;
|
||||
years: string;
|
||||
location?: string;
|
||||
};
|
||||
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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 = {
|
||||
|
Loading…
Reference in New Issue
Block a user