diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index ecea33d08ab..a2ec0a90a90 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -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'] diff --git a/public/app/features/alerting/unified/MuteTimings.test.tsx b/public/app/features/alerting/unified/MuteTimings.test.tsx index 369fee9a77b..53d91ce4dc3 100644 --- a/public/app/features/alerting/unified/MuteTimings.test.tsx +++ b/public/app/features/alerting/unified/MuteTimings.test.tsx @@ -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(); diff --git a/public/app/features/alerting/unified/MuteTimings.tsx b/public/app/features/alerting/unified/MuteTimings.tsx index f98583b8b3d..78d9aa558a2 100644 --- a/public/app/features/alerting/unified/MuteTimings.tsx +++ b/public/app/features/alerting/unified/MuteTimings.tsx @@ -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 && } - {error && !loading && ( + {error && !loading && !result && ( {error.message || 'Unknown error.'} @@ -65,7 +64,7 @@ const MuteTimings = () => { {result && !error && ( - + {() => { @@ -73,7 +72,14 @@ const MuteTimings = () => { const muteTiming = getMuteTimingByName(String(queryParams['muteName'])); const provenance = muteTiming?.provenance; - return ; + return ( + + ); } return ; }} diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx index 3270dcddfdb..92b987db209 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx @@ -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 = { 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 && } - {result && !loading && ( + {loading && } + {showError && } + {result && !loading && !showError && (
- {showError && } -
+
{ 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) => { /> - Cancel diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx index 4fab679bafd..84ccd821fc8 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx @@ -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 ( -
+
<>

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.

- {timeIntervals.map((timeInterval, timeIntervalIndex) => { - const errors = formState.errors; - return ( -
- - - - 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" - /> - - - - 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" - /> - - - - 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" - /> - - - 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" - /> - - -
- ); - })} + + {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 ( +
+ + + } + 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" + /> + + + { + setValue(`time_intervals.${timeIntervalIndex}.weekdays`, daysOfWeek); + }} + // @ts-ignore react-hook-form doesn't handle nested field arrays well + defaultValue={timeInterval.weekdays} + /> + + + + 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" + /> + + + + 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" + /> + + + 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" + /> + + +
+ ); + })} +
+ ); + })} + + + ); +}; + 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}; + } `, }); diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx index 91129713b10..d80d69f5c78 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeRange.tsx @@ -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(); + const { register, formState, getValues } = useFormContext(); 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 (
- + { + 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={} // @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" /> - + { + 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={} // @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" /> @@ -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)}; diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx index c641f7bc821..3ac67558f9b 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.tsx @@ -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)} /> ) : ( -

No mute timings configured

+ )} {!hideActions && ( 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 ( - - {`${timeString} ${weekdayString}`} -
- {[daysString, monthsString, yearsString].join(' | ')} -
-
- ); - }); -} - const getStyles = (theme: GrafanaTheme2) => ({ container: css` display: flex; diff --git a/public/app/features/alerting/unified/components/mute-timings/__snapshots__/util.test.tsx.snap b/public/app/features/alerting/unified/components/mute-timings/__snapshots__/util.test.tsx.snap new file mode 100644 index 00000000000..a544b76e0de --- /dev/null +++ b/public/app/features/alerting/unified/components/mute-timings/__snapshots__/util.test.tsx.snap @@ -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`] = ` +[ + + Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun +
+ Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 +
+
, + + Times: 12:00 - 13:00 [Europe/Berlin] and 14:00 - 15:00 [Europe/Berlin] Weekdays: Mon, Tue-Thu, Sun +
+ Days of the month: 1, 2:4, 31 | Months: january, february:march, december | Years: 2019, 2020:2021 +
+
, +] +`; + +exports[`renderTimeIntervals should render time interval with time range 1`] = ` +[ + + Times: 12:00 - 13:00 [UTC] and 14:00 - 15:00 [UTC] Weekdays: All +
+ Days of the month: All | Months: All | Years: All +
+
, +] +`; + +exports[`renderTimeIntervals should render time interval with weekdays 1`] = ` +[ + + Times: All Weekdays: Mon, Tue-Thu, Sun +
+ Days of the month: All | Months: All | Years: All +
+
, +] +`; diff --git a/public/app/features/alerting/unified/components/mute-timings/timezones.tsx b/public/app/features/alerting/unified/components/mute-timings/timezones.tsx new file mode 100644 index 00000000000..9e0ca1e8a1a --- /dev/null +++ b/public/app/features/alerting/unified/components/mute-timings/timezones.tsx @@ -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) => { + const timezoneOptions: Array> = TIMEZONES.map((tz) => ({ + label: tz, + value: tz, + })); + + return {...options} options={timezoneOptions} />; +}; diff --git a/public/app/features/alerting/unified/components/mute-timings/util.test.tsx b/public/app/features/alerting/unified/components/mute-timings/util.test.tsx new file mode 100644 index 00000000000..e5537ef9864 --- /dev/null +++ b/public/app/features/alerting/unified/components/mute-timings/util.test.tsx @@ -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(); + }); +}); diff --git a/public/app/features/alerting/unified/components/mute-timings/util.tsx b/public/app/features/alerting/unified/components/mute-timings/util.tsx new file mode 100644 index 00000000000..dda90ae1b39 --- /dev/null +++ b/public/app/features/alerting/unified/components/mute-timings/util.tsx @@ -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 ( + + {`${timeString} ${weekdayString}`} +
+ {[daysString, monthsString, yearsString].join(' | ')} +
+
+ ); + }); +} + +export { isvalidTimeFormat, isValidStartAndEndTime, renderTimeIntervals }; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 471603c3478..b24f08115b8 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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( 'unifiedalerting/updateAMConfig', - ({ alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath, refetch }, thunkAPI): Promise => + ( + { alertManagerSourceName, oldConfig, newConfig, successMessage, redirectPath, redirectSearch, refetch }, + thunkAPI + ): Promise => withAppEvents( withSerializedError( (async () => { @@ -579,7 +583,8 @@ export const updateAlertManagerConfigAction = createAsyncThunk `${start_time} - ${end_time} UTC`).join(' and ') : 'All') + (times + ? times?.map(({ start_time, end_time }) => `${start_time} - ${end_time} [${location ?? 'UTC'}]`).join(' and ') + : 'All') ); } diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 620e801962b..04eaae437ee 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -92,8 +92,10 @@ export function recordToArray(record: Record): Array<{ key: stri return Object.entries(record).map(([key, value]) => ({ key, value })); } -export function makeAMLink(path: string, alertManagerName?: string, options?: Record): string { +type URLParamsLike = ConstructorParameters[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); } diff --git a/public/app/features/alerting/unified/utils/mute-timings.ts b/public/app/features/alerting/unified/utils/mute-timings.ts index 47e51df4935..abac60fda0a 100644 --- a/public/app/features/alerting/unified/utils/mute-timings.ts +++ b/public/app/features/alerting/unified/utils/mute-timings.ts @@ -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); diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 17a3c192427..5d7c2fab86b 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -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 = {