mirror of
https://github.com/grafana/grafana.git
synced 2024-11-25 18:30:41 -06:00
Alerting: Add duration field to silence editor (#34029)
This commit is contained in:
parent
bc21adf712
commit
889d3ed76f
@ -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",
|
||||
|
23
packages/grafana-data/src/datetime/durationutil.test.ts
Normal file
23
packages/grafana-data/src/datetime/durationutil.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
45
packages/grafana-data/src/datetime/durationutil.ts
Normal file
45
packages/grafana-data/src/datetime/durationutil.ts
Normal 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));
|
||||
}
|
@ -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';
|
||||
|
@ -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 />
|
||||
|
@ -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)}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user