mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add duration field to silence editor (#34029)
This commit is contained in:
parent
bc21adf712
commit
889d3ed76f
@ -24,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "5.0.1",
|
"@braintree/sanitize-url": "5.0.1",
|
||||||
"@types/d3-interpolate": "^1.3.1",
|
"@types/d3-interpolate": "^1.3.1",
|
||||||
|
"date-fns": "^2.21.3",
|
||||||
"eventemitter3": "4.0.7",
|
"eventemitter3": "4.0.7",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"marked": "2.0.1",
|
"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 './formats';
|
||||||
export * from './formatter';
|
export * from './formatter';
|
||||||
export * from './parser';
|
export * from './parser';
|
||||||
|
export * from './durationutil';
|
||||||
export { dateMath, rangeUtil };
|
export { dateMath, rangeUtil };
|
||||||
export { DateTimeOptions, setTimeZoneResolver, TimeZoneResolver, getTimeZone } from './common';
|
export { DateTimeOptions, setTimeZoneResolver, TimeZoneResolver, getTimeZone } from './common';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { FC, Fragment, useState } from 'react';
|
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 { css, cx } from '@emotion/css';
|
||||||
import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { CollapseToggle } from '../CollapseToggle';
|
import { CollapseToggle } from '../CollapseToggle';
|
||||||
@ -29,7 +29,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||||
const startsAtDate = dateMath.parse(startsAt);
|
const startsAtDate = dateMath.parse(startsAt);
|
||||||
const endsAtDate = dateMath.parse(endsAt);
|
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 = () => {
|
const handleExpireSilenceClick = () => {
|
||||||
dispatch(expireSilenceAction(alertManagerSourceName, silence.id));
|
dispatch(expireSilenceAction(alertManagerSourceName, silence.id));
|
||||||
@ -89,7 +89,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
<tr className={className}>
|
<tr className={className}>
|
||||||
<td />
|
<td />
|
||||||
<td>Duration</td>
|
<td>Duration</td>
|
||||||
<td colSpan={4}>{duration} seconds</td>
|
<td colSpan={4}>{duration}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className={className}>
|
<tr className={className}>
|
||||||
<td />
|
<td />
|
||||||
|
@ -1,7 +1,16 @@
|
|||||||
import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
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 { 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 { config } from '@grafana/runtime';
|
||||||
import { pickBy } from 'lodash';
|
import { pickBy } from 'lodash';
|
||||||
import MatchersField from './MatchersField';
|
import MatchersField from './MatchersField';
|
||||||
@ -21,14 +30,22 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultFormValues = (silence?: Silence): SilenceFormFields => {
|
const getDefaultFormValues = (silence?: Silence): SilenceFormFields => {
|
||||||
|
const now = new Date();
|
||||||
if (silence) {
|
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 {
|
return {
|
||||||
id: silence.id,
|
id: silence.id,
|
||||||
startsAt: new Date().toISOString(),
|
startsAt: interval.start.toISOString(),
|
||||||
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h
|
endsAt: interval.end.toISOString(),
|
||||||
comment: silence.comment,
|
comment: silence.comment,
|
||||||
createdBy: silence.createdBy,
|
createdBy: silence.createdBy,
|
||||||
duration: `2h`,
|
duration: intervalToAbbreviatedDurationString(interval),
|
||||||
isRegex: false,
|
isRegex: false,
|
||||||
matchers: silence.matchers || [],
|
matchers: silence.matchers || [],
|
||||||
matcherName: '',
|
matcherName: '',
|
||||||
@ -36,10 +53,11 @@ const getDefaultFormValues = (silence?: Silence): SilenceFormFields => {
|
|||||||
timeZone: DefaultTimeZone,
|
timeZone: DefaultTimeZone,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
const endsAt = addDurationToDate(now, { hours: 2 }); // Default time period is now + 2h
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
startsAt: new Date().toISOString(),
|
startsAt: now.toISOString(),
|
||||||
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h
|
endsAt: endsAt.toISOString(),
|
||||||
comment: '',
|
comment: '',
|
||||||
createdBy: config.bootData.user.name,
|
createdBy: config.bootData.user.name,
|
||||||
duration: '2h',
|
duration: '2h',
|
||||||
@ -61,7 +79,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
|
|
||||||
useCleanup((state) => state.unifiedAlerting.updateSilence);
|
useCleanup((state) => state.unifiedAlerting.updateSilence);
|
||||||
|
|
||||||
const { register, handleSubmit, formState } = formAPI;
|
const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI;
|
||||||
|
|
||||||
const onSubmit = (data: SilenceFormFields) => {
|
const onSubmit = (data: SilenceFormFields) => {
|
||||||
const { id, startsAt, endsAt, comment, createdBy, matchers } = data;
|
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 (
|
return (
|
||||||
<FormProvider {...formAPI}>
|
<FormProvider {...formAPI}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@ -94,7 +143,29 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
{error.message || (error as any)?.data?.message || String(error)}
|
{error.message || (error as any)?.data?.message || String(error)}
|
||||||
</Alert>
|
</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 />
|
<MatchersField />
|
||||||
<Field
|
<Field
|
||||||
className={cx(styles.field, styles.textArea)}
|
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"
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
|
||||||
integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
|
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:
|
date-format@^0.0.0:
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/date-format/-/date-format-0.0.0.tgz#09206863ab070eb459acea5542cbd856b11966b3"
|
resolved "https://registry.yarnpkg.com/date-format/-/date-format-0.0.0.tgz#09206863ab070eb459acea5542cbd856b11966b3"
|
||||||
|
Loading…
Reference in New Issue
Block a user