From d994d0e762797b70ff4e8558b03365a6f464dc6e Mon Sep 17 00:00:00 2001 From: Nathan Rodman Date: Thu, 6 May 2021 00:29:02 -0700 Subject: [PATCH] Alerting: Create and edit silences (#33593) --- .../features/alerting/unified/Silences.tsx | 56 +++++-- .../alerting/unified/api/alertmanager.ts | 4 +- .../unified/components/AlertLabel.tsx | 6 +- .../unified/components/AlertLabels.tsx | 14 +- .../unified/components/silences/Matchers.tsx | 47 ++++++ .../components/silences/MatchersField.tsx | 121 ++++++++++++++ .../components/silences/NoSilencesCTA.tsx | 10 +- .../components/silences/SilencePeriod.tsx | 78 +++++++++ .../components/silences/SilenceTableRow.tsx | 18 +- .../components/silences/SilencesEditor.tsx | 156 ++++++++++++++++++ .../components/silences/SilencesTable.tsx | 129 ++++++++++----- .../alerting/unified/state/actions.ts | 31 +++- .../alerting/unified/state/reducers.ts | 2 + .../alerting/unified/types/silence-form.ts | 16 ++ public/app/routes/routes.tsx | 12 ++ 15 files changed, 614 insertions(+), 86 deletions(-) create mode 100644 public/app/features/alerting/unified/components/silences/Matchers.tsx create mode 100644 public/app/features/alerting/unified/components/silences/MatchersField.tsx create mode 100644 public/app/features/alerting/unified/components/silences/SilencePeriod.tsx create mode 100644 public/app/features/alerting/unified/components/silences/SilencesEditor.tsx create mode 100644 public/app/features/alerting/unified/types/silence-form.ts diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index 20fb61e6534..787240c92d1 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -1,15 +1,16 @@ -import { Field, Alert, LoadingPlaceholder } from '@grafana/ui'; -import React, { FC, useEffect } from 'react'; +import React, { FC, useEffect, useCallback } from 'react'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; + import { useDispatch } from 'react-redux'; -import { Redirect } from 'react-router-dom'; +import { Redirect, Route, RouteChildrenProps, Switch } from 'react-router-dom'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { AlertManagerPicker } from './components/AlertManagerPicker'; +import SilencesTable from './components/silences/SilencesTable'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { fetchAmAlertsAction, fetchSilencesAction } from './state/actions'; import { SILENCES_POLL_INTERVAL_MS } from './utils/constants'; import { initialAsyncRequestState } from './utils/redux'; -import SilencesTable from './components/silences/SilencesTable'; +import SilencesEditor from './components/silences/SilencesEditor'; const Silences: FC = () => { const [alertManagerSourceName = '', setAlertManagerSourceName] = useAlertManagerSourceName(); @@ -21,8 +22,10 @@ const Silences: FC = () => { useEffect(() => { function fetchAll() { - dispatch(fetchSilencesAction(alertManagerSourceName)); - dispatch(fetchAmAlertsAction(alertManagerSourceName)); + if (alertManagerSourceName) { + dispatch(fetchSilencesAction(alertManagerSourceName)); + dispatch(fetchAmAlertsAction(alertManagerSourceName)); + } } fetchAll(); const interval = setInterval(() => fetchAll, SILENCES_POLL_INTERVAL_MS); @@ -31,18 +34,15 @@ const Silences: FC = () => { }; }, [alertManagerSourceName, dispatch]); + const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState; + const getSilenceById = useCallback((id: string) => result && result.find((silence) => silence.id === id), [result]); + if (!alertManagerSourceName) { return ; } - const { result, loading, error } = silences[alertManagerSourceName] || initialAsyncRequestState; return ( - - - -
-
{error && !loading && ( {error.message || 'Unknown error.'} @@ -50,11 +50,31 @@ const Silences: FC = () => { )} {loading && } {result && !error && alerts.result && ( - + + + + + + + + + {({ match }: RouteChildrenProps<{ id: string }>) => { + return ( + match?.params.id && ( + + ) + ); + }} + + )}
); diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 16689b7f216..944cb2f6dbd 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -70,12 +70,12 @@ export async function fetchSilences(alertManagerSourceName: string): Promise { +): Promise { const result = await getBackendSrv().post( `/api/alertmanager/${getDatasourceAPIId(alertmanagerSourceName)}/api/v2/silences`, payload ); - return result.data.silenceID; + return result.data; } export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise { diff --git a/public/app/features/alerting/unified/components/AlertLabel.tsx b/public/app/features/alerting/unified/components/AlertLabel.tsx index 0e25d4f5892..6594a8c82c6 100644 --- a/public/app/features/alerting/unified/components/AlertLabel.tsx +++ b/public/app/features/alerting/unified/components/AlertLabel.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { useStyles } from '@grafana/ui'; +import { IconButton, useStyles } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; import { css } from '@emotion/css'; @@ -7,12 +7,14 @@ interface Props { labelKey: string; value: string; isRegex?: boolean; + onRemoveLabel?: () => void; } -export const AlertLabel: FC = ({ labelKey, value, isRegex = false }) => ( +export const AlertLabel: FC = ({ labelKey, value, isRegex = false, onRemoveLabel }) => (
{labelKey}={isRegex && '~'} {value} + {!!onRemoveLabel && }
); diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx index c85203cab1a..9c886ec8eb0 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -1,23 +1,19 @@ import { GrafanaTheme } from '@grafana/data'; import { useStyles } from '@grafana/ui'; import { css } from '@emotion/css'; -import React, { FC } from 'react'; +import React from 'react'; import { AlertLabel } from './AlertLabel'; -interface Props { - labels: Record; -} +type Props = { labels: Record }; -export const AlertLabels: FC = ({ labels }) => { +export const AlertLabels = ({ labels }: Props) => { const styles = useStyles(getStyles); - - // transform to array of key value pairs and filter out "private" labels that start and end with double underscore const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__'))); return (
- {pairs.map(([key, value]) => ( - + {pairs.map(([key, value], index) => ( + ))}
); diff --git a/public/app/features/alerting/unified/components/silences/Matchers.tsx b/public/app/features/alerting/unified/components/silences/Matchers.tsx new file mode 100644 index 00000000000..68813866d71 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/Matchers.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { SilenceMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { AlertLabel } from '../AlertLabel'; + +type MatchersProps = { matchers: SilenceMatcher[]; onRemoveLabel?(index: number): void }; + +export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => { + const styles = useStyles(getStyles); + + const removeLabel = useCallback( + (index: number) => { + if (!!onRemoveLabel) { + onRemoveLabel(index); + } + }, + [onRemoveLabel] + ); + + return ( +
+ {matchers.map(({ name, value, isRegex }: SilenceMatcher, index) => { + return ( + removeLabel(index) : undefined} + /> + ); + })} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + & > * { + margin-top: ${theme.spacing.xs}; + margin-right: ${theme.spacing.xs}; + } + padding-bottom: ${theme.spacing.xs}; + `, +}); diff --git a/public/app/features/alerting/unified/components/silences/MatchersField.tsx b/public/app/features/alerting/unified/components/silences/MatchersField.tsx new file mode 100644 index 00000000000..a31ab86994f --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/MatchersField.tsx @@ -0,0 +1,121 @@ +import React, { FC } from 'react'; +import { Button, Field, Input, InlineLabel, useStyles, Checkbox, IconButton } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { useFormContext, useFieldArray } from 'react-hook-form'; +import { SilenceFormFields } from '../../types/silence-form'; + +interface Props { + className?: string; +} + +const MatchersField: FC = ({ className }) => { + const styles = useStyles(getStyles); + const formApi = useFormContext(); + const { + register, + formState: { errors }, + } = formApi; + const { fields: matchers = [], append, remove } = useFieldArray({ name: 'matchers' }); + + return ( +
+ +
+
+ {matchers.map((matcher, index) => { + return ( +
+ + + + = + + + + + + + {matchers.length > 1 && ( + remove(index)} + > + Remove + + )} +
+ ); + })} +
+ +
+
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + wrapper: css` + margin-top: ${theme.spacing.md}; + `, + row: css` + display: flex; + flex-direction: row; + align-items: center; + background-color: ${theme.colors.bg2}; + padding: ${theme.spacing.sm} ${theme.spacing.sm} 0 ${theme.spacing.sm}; + `, + equalSign: css` + width: 28px; + justify-content: center; + margin-left: ${theme.spacing.xs}; + margin-bottom: 0; + `, + regexCheckbox: css` + margin-left: ${theme.spacing.md}; + `, + removeButton: css` + margin-left: ${theme.spacing.sm}; + `, + matchers: css` + max-width: 585px; + margin: ${theme.spacing.sm} 0; + padding-top: ${theme.spacing.xs}; + `, + }; +}; + +export default MatchersField; diff --git a/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx b/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx index 23a20df3d74..f37aee1ea26 100644 --- a/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx +++ b/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx @@ -1,12 +1,16 @@ import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import React, { FC } from 'react'; -import { config } from '@grafana/runtime'; +import { makeAMLink } from '../../utils/misc'; -export const NoSilencesSplash: FC = () => ( +type Props = { + alertManagerSourceName: string; +}; + +export const NoSilencesSplash: FC = ({ alertManagerSourceName }) => ( ); diff --git a/public/app/features/alerting/unified/components/silences/SilencePeriod.tsx b/public/app/features/alerting/unified/components/silences/SilencePeriod.tsx new file mode 100644 index 00000000000..c9897f45862 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/SilencePeriod.tsx @@ -0,0 +1,78 @@ +import { css } from '@emotion/css'; +import { dateTime, GrafanaTheme } from '@grafana/data'; +import { Field, TimeRangeInput, useStyles } from '@grafana/ui'; +import React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import { SilenceFormFields } from '../../types/silence-form'; + +export const SilencePeriod = () => { + const { control, getValues } = useFormContext(); + const styles = useStyles(getStyles); + const { + field: { onChange: onChangeStartsAt, value: startsAt }, + fieldState: { invalid: startsAtInvalid }, + } = useController({ + name: 'startsAt', + control, + rules: { + validate: (value) => getValues().endsAt > value, + }, + }); + + const { + field: { onChange: onChangeEndsAt, value: endsAt }, + fieldState: { invalid: endsAtInvalid }, + } = useController({ + name: 'endsAt', + control, + rules: { + validate: (value) => getValues().startsAt < value, + }, + }); + + const { + field: { onChange: onChangeTimeZone, value: timeZone }, + } = useController({ + name: 'timeZone', + control, + }); + + const invalid = startsAtInvalid || endsAtInvalid; + + const from = dateTime(startsAt); + const to = dateTime(endsAt); + + return ( + + { + onChangeStartsAt(dateTime(newValue.from)); + onChangeEndsAt(dateTime(newValue.to)); + }} + onChangeTimeZone={(newValue) => onChangeTimeZone(newValue)} + hideTimeZone={false} + hideQuickRanges={true} + /> + + ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + timeRange: css` + width: 400px; + `, +}); diff --git a/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx b/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx index 77e1f44fd39..cdffef1ab61 100644 --- a/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx +++ b/public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx @@ -2,15 +2,15 @@ import React, { FC, Fragment, useState } from 'react'; import { dateMath, GrafanaTheme, toDuration } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { Silence, AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types'; -import { AlertLabel } from '../AlertLabel'; import { StateTag } from '../StateTag'; import { CollapseToggle } from '../CollapseToggle'; import { ActionButton } from '../rules/ActionButton'; import { ActionIcon } from '../rules/ActionIcon'; -import { useStyles } from '@grafana/ui'; +import { useStyles, Link } from '@grafana/ui'; import SilencedAlertsTable from './SilencedAlertsTable'; import { expireSilenceAction } from '../../state/actions'; import { useDispatch } from 'react-redux'; +import { Matchers } from './Matchers'; interface Props { className?: string; silence: Silence; @@ -23,7 +23,7 @@ const SilenceTableRow: FC = ({ silence, className, silencedAlerts, alertM const dispatch = useDispatch(); const styles = useStyles(getStyles); - const { status, matchers, startsAt, endsAt, comment, createdBy } = silence; + const { status, matchers = [], startsAt, endsAt, comment, createdBy } = silence; const dateDisplayFormat = 'YYYY-MM-DD HH:mm'; const startsAtDate = dateMath.parse(startsAt); @@ -44,9 +44,7 @@ const SilenceTableRow: FC = ({ silence, className, silencedAlerts, alertM {status.state} - {matchers?.map(({ name, value, isRegex }) => { - return ; - })} + {silencedAlerts.length} @@ -56,13 +54,17 @@ const SilenceTableRow: FC = ({ silence, className, silencedAlerts, alertM {status.state === 'expired' ? ( - Recreate + + Recreate + ) : ( Unsilence )} - + {status.state !== 'expired' && ( + + )} {!isCollapsed && ( diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx new file mode 100644 index 00000000000..0b57a5bf015 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -0,0 +1,156 @@ +import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; +import React, { FC } from 'react'; +import { Alert, Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles } from '@grafana/ui'; +import { DefaultTimeZone, GrafanaTheme } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { pickBy } from 'lodash'; +import MatchersField from './MatchersField'; +import { useForm, FormProvider } from 'react-hook-form'; +import { SilenceFormFields } from '../../types/silence-form'; +import { useDispatch } from 'react-redux'; +import { createOrUpdateSilenceAction } from '../../state/actions'; +import { SilencePeriod } from './SilencePeriod'; +import { css, cx } from '@emotion/css'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { makeAMLink } from '../../utils/misc'; + +interface Props { + silence?: Silence; + alertManagerSourceName: string; +} + +const getDefaultFormValues = (silence?: Silence): SilenceFormFields => { + if (silence) { + return { + id: silence.id, + startsAt: new Date().toISOString(), + endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h + comment: silence.comment, + createdBy: silence.createdBy, + duration: `2h`, + isRegex: false, + matchers: silence.matchers || [], + matcherName: '', + matcherValue: '', + timeZone: DefaultTimeZone, + }; + } else { + return { + id: '', + startsAt: new Date().toISOString(), + endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // Default time period is now + 2h + comment: '', + createdBy: config.bootData.user.name, + duration: '2h', + isRegex: false, + matchers: [{ name: '', value: '', isRegex: false }], + matcherName: '', + matcherValue: '', + timeZone: DefaultTimeZone, + }; + } +}; + +export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) => { + const formAPI = useForm({ defaultValues: getDefaultFormValues(silence) }); + const dispatch = useDispatch(); + const styles = useStyles(getStyles); + + const { loading, error } = useUnifiedAlertingSelector((state) => state.updateSilence); + + const { register, handleSubmit, formState } = formAPI; + + const onSubmit = (data: SilenceFormFields) => { + const { id, startsAt, endsAt, comment, createdBy, matchers } = data; + const payload = pickBy( + { + id, + startsAt, + endsAt, + comment, + createdBy, + matchers, + }, + (value) => !!value + ) as SilenceCreatePayload; + dispatch( + createOrUpdateSilenceAction({ + alertManagerSourceName, + payload, + exitOnSave: true, + successMessage: `Silence ${payload.id ? 'updated' : 'created'}`, + }) + ); + }; + return ( + +
+
+ {error && ( + + {error.message || (error as any)?.data?.message || String(error)} + + )} + + + +