Alerting: Add duration field to silence editor (#34029)

This commit is contained in:
Nathan Rodman 2021-05-16 22:59:27 -07:00 committed by GitHub
parent bc21adf712
commit 889d3ed76f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 158 additions and 12 deletions

View File

@ -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",

View File

@ -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' });
});
});
});

View File

@ -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<keyof Duration>]: 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<Duration>((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));
}

View File

@ -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';

View File

@ -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<Props> = ({ 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<Props> = ({ silence, className, silencedAlerts, alertM
<tr className={className}>
<td />
<td>Duration</td>
<td colSpan={4}>{duration} seconds</td>
<td colSpan={4}>{duration}</td>
</tr>
<tr className={className}>
<td />

View File

@ -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<Props> = ({ 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<Props> = ({ 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 (
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
@ -94,7 +143,29 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
{error.message || (error as any)?.data?.message || String(error)}
</Alert>
)}
<SilencePeriod />
<div className={styles.flexRow}>
<SilencePeriod />
<Field
label="Duration"
invalid={!!formState.errors.duration}
error={
formState.errors.duration &&
(formState.errors.duration.type === 'required' ? 'Required field' : formState.errors.duration.message)
}
>
<Input
className={styles.createdBy}
{...register('duration', {
validate: (value) =>
Object.keys(parseDuration(value)).length === 0
? 'Invalid duration. Valid example: 1d 4h (Available units: y, M, w, d, h, m, s)'
: undefined,
})}
id="duration"
/>
</Field>
</div>
<MatchersField />
<Field
className={cx(styles.field, styles.textArea)}

View File

@ -10484,6 +10484,11 @@ date-fns@^1.23.0, date-fns@^1.27.2:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
date-fns@^2.21.3:
version "2.21.3"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.21.3.tgz#8f5f6889d7a96bbcc1f0ea50239b397a83357f9b"
integrity sha512-HeYdzCaFflc1i4tGbj7JKMjM4cKGYoyxwcIIkHzNgCkX8xXDNJDZXgDDVchIWpN4eQc3lH37WarduXFZJOtxfw==
date-format@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/date-format/-/date-format-0.0.0.tgz#09206863ab070eb459acea5542cbd856b11966b3"