diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 2a5485bc97d..6ad24e902e8 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -24,6 +24,7 @@ "dependencies": { "@braintree/sanitize-url": "5.0.1", "@types/d3-interpolate": "^1.3.1", + "date-fns": "^2.21.3", "eventemitter3": "4.0.7", "lodash": "4.17.21", "marked": "2.0.1", diff --git a/packages/grafana-data/src/datetime/durationutil.test.ts b/packages/grafana-data/src/datetime/durationutil.test.ts new file mode 100644 index 00000000000..7912c653372 --- /dev/null +++ b/packages/grafana-data/src/datetime/durationutil.test.ts @@ -0,0 +1,23 @@ +import { intervalToAbbreviatedDurationString, addDurationToDate, parseDuration } from './durationutil'; + +describe('Duration util', () => { + describe('intervalToAbbreviatedDurationString', () => { + it('creates a duration string for a provided interval', () => { + const startDate = new Date(); + const endDate = addDurationToDate(startDate, { months: 1, weeks: 1, days: 1, hours: 1, minutes: 1, seconds: 1 }); + expect(intervalToAbbreviatedDurationString({ start: startDate, end: endDate })).toEqual('1M 8d 1h 1m 1s'); + }); + }); + + describe('parseDuration', () => { + it('parses a duration string', () => { + const durationString = '3M 5d 20m'; + expect(parseDuration(durationString)).toEqual({ months: '3', days: '5', minutes: '20' }); + }); + + it('strips out non valid durations', () => { + const durationString = '3M 6v 5b 4m'; + expect(parseDuration(durationString)).toEqual({ months: '3', minutes: '4' }); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/durationutil.ts b/packages/grafana-data/src/datetime/durationutil.ts new file mode 100644 index 00000000000..80a1d666a03 --- /dev/null +++ b/packages/grafana-data/src/datetime/durationutil.ts @@ -0,0 +1,45 @@ +import { Duration, Interval } from 'date-fns'; +import intervalToDuration from 'date-fns/intervalToDuration'; +import add from 'date-fns/add'; + +const durationMap: { [key in Required]: string[] } = { + years: ['y', 'Y', 'years'], + months: ['M', 'months'], + weeks: ['w', 'W', 'weeks'], + days: ['d', 'D', 'days'], + hours: ['h', 'H', 'hours'], + minutes: ['m', 'minutes'], + seconds: ['s', 'S', 'seconds'], +}; + +export function intervalToAbbreviatedDurationString(interval: Interval): string { + const duration = intervalToDuration(interval); + return (Object.entries(duration) as Array<[keyof Duration, number | undefined]>).reduce((str, [unit, value]) => { + if (value && value !== 0) { + const padding = str !== '' ? ' ' : ''; + return str + `${padding}${value}${durationMap[unit][0]}`; + } + + return str; + }, ''); +} + +export function parseDuration(duration: string): Duration { + return duration.split(' ').reduce((acc, value) => { + const match = value.match(/(\d+)(.+)/); + if (match === null || match.length !== 3) { + return acc; + } + + const key = Object.entries(durationMap).find(([_, abbreviations]) => abbreviations?.includes(match[2]))?.[0]; + return !key ? acc : { ...acc, [key]: match[1] }; + }, {}); +} + +export function addDurationToDate(date: Date | number, duration: Duration): Date { + return add(date, duration); +} + +export function isValidDate(dateString: string) { + return !isNaN(Date.parse(dateString)); +} diff --git a/packages/grafana-data/src/datetime/index.ts b/packages/grafana-data/src/datetime/index.ts index b3e0cc28500..c4d1982092d 100644 --- a/packages/grafana-data/src/datetime/index.ts +++ b/packages/grafana-data/src/datetime/index.ts @@ -6,5 +6,6 @@ export * from './timezones'; export * from './formats'; export * from './formatter'; export * from './parser'; +export * from './durationutil'; export { dateMath, rangeUtil }; export { DateTimeOptions, setTimeZoneResolver, TimeZoneResolver, getTimeZone } from './common'; diff --git a/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx b/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx index f87c77548cc..efab9b5e3a6 100644 --- a/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx +++ b/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx @@ -1,5 +1,5 @@ import React, { FC, Fragment, useState } from 'react'; -import { dateMath, GrafanaTheme, toDuration } from '@grafana/data'; +import { dateMath, GrafanaTheme, intervalToAbbreviatedDurationString } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types'; import { CollapseToggle } from '../CollapseToggle'; @@ -29,7 +29,7 @@ const SilenceTableRow: FC = ({ silence, className, silencedAlerts, alertM const dateDisplayFormat = 'YYYY-MM-DD HH:mm'; const startsAtDate = dateMath.parse(startsAt); const endsAtDate = dateMath.parse(endsAt); - const duration = toDuration(endsAtDate?.diff(startsAtDate || '')).asSeconds(); + const duration = intervalToAbbreviatedDurationString({ start: new Date(startsAt), end: new Date(endsAt) }); const handleExpireSilenceClick = () => { dispatch(expireSilenceAction(alertManagerSourceName, silence.id)); @@ -89,7 +89,7 @@ const SilenceTableRow: FC = ({ silence, className, silencedAlerts, alertM Duration - {duration} seconds + {duration} diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 09fcb31c19f..796227bca85 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -1,7 +1,16 @@ import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { Alert, Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles } from '@grafana/ui'; -import { DefaultTimeZone, GrafanaTheme } from '@grafana/data'; +import { + DefaultTimeZone, + GrafanaTheme, + parseDuration, + intervalToAbbreviatedDurationString, + addDurationToDate, + dateTime, + isValidDate, +} from '@grafana/data'; +import { useDebounce } from 'react-use'; import { config } from '@grafana/runtime'; import { pickBy } from 'lodash'; import MatchersField from './MatchersField'; @@ -21,14 +30,22 @@ interface Props { } const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { + const now = new Date(); if (silence) { + const isExpired = Date.parse(silence.endsAt) < Date.now(); + const interval = isExpired + ? { + start: now, + end: addDurationToDate(now, { hours: 2 }), + } + : { start: new Date(silence.startsAt), end: new Date(silence.endsAt) }; return { id: silence.id, - startsAt: new Date().toISOString(), - endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h + startsAt: interval.start.toISOString(), + endsAt: interval.end.toISOString(), comment: silence.comment, createdBy: silence.createdBy, - duration: `2h`, + duration: intervalToAbbreviatedDurationString(interval), isRegex: false, matchers: silence.matchers || [], matcherName: '', @@ -36,10 +53,11 @@ const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { timeZone: DefaultTimeZone, }; } else { + const endsAt = addDurationToDate(now, { hours: 2 }); // Default time period is now + 2h return { id: '', - startsAt: new Date().toISOString(), - endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h + startsAt: now.toISOString(), + endsAt: endsAt.toISOString(), comment: '', createdBy: config.bootData.user.name, duration: '2h', @@ -61,7 +79,7 @@ export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) = useCleanup((state) => state.unifiedAlerting.updateSilence); - const { register, handleSubmit, formState } = formAPI; + const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI; const onSubmit = (data: SilenceFormFields) => { const { id, startsAt, endsAt, comment, createdBy, matchers } = data; @@ -85,6 +103,37 @@ export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) = }) ); }; + + const duration = watch('duration'); + const startsAt = watch('startsAt'); + const endsAt = watch('endsAt'); + + // Keep duration and endsAt in sync + const [prevDuration, setPrevDuration] = useState(duration); + useDebounce( + () => { + if (isValidDate(startsAt) && isValidDate(endsAt)) { + if (duration !== prevDuration) { + setValue('endsAt', dateTime(addDurationToDate(new Date(startsAt), parseDuration(duration))).toISOString()); + setPrevDuration(duration); + } else { + const startValue = new Date(startsAt).valueOf(); + const endValue = new Date(endsAt).valueOf(); + if (endValue > startValue) { + const nextDuration = intervalToAbbreviatedDurationString({ + start: new Date(startsAt), + end: new Date(endsAt), + }); + setValue('duration', nextDuration); + setPrevDuration(nextDuration); + } + } + } + }, + 700, + [clearErrors, duration, endsAt, prevDuration, setValue, startsAt] + ); + return (
@@ -94,7 +143,29 @@ export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) = {error.message || (error as any)?.data?.message || String(error)} )} - +
+ + + + Object.keys(parseDuration(value)).length === 0 + ? 'Invalid duration. Valid example: 1d 4h (Available units: y, M, w, d, h, m, s)' + : undefined, + })} + id="duration" + /> + +
+