From dfea60e7368ed46a712df7b71ea7b405009e702f Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Fri, 24 May 2024 11:32:19 +0300 Subject: [PATCH] Allow custom silence period for alert groups + display how much left is for a silenced AG (#4375) # What this PR does https://github.com/grafana/oncall/issues/2333 - Allow custom input for silence period (either by duration or by setting a future date in the calendar, both inputs are syncedd/editable) - Show how much time is left for a silenced alert group ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall/issues/2333 --- .../RenderConditionally.tsx | 17 +- .../src/models/alertgroup/alertgroup.ts | 2 +- .../src/pages/incident/Incident.tsx | 41 ++++- .../src/pages/incidents/Incidents.tsx | 1 + .../incidents/parts/IncidentDropdown.tsx | 152 +++++++++++------- .../incidents/parts/IncidentSilenceModal.tsx | 111 +++++++++++++ .../incidents/parts/SilenceButtonCascader.tsx | 19 ++- .../pages/incidents/parts/SilenceSelect.tsx | 39 +++-- grafana-plugin/src/styles/utils.styles.ts | 4 + 9 files changed, 300 insertions(+), 86 deletions(-) create mode 100644 grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx diff --git a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx index 0983e9fc..ca2c23b9 100644 --- a/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx +++ b/grafana-plugin/src/components/RenderConditionally/RenderConditionally.tsx @@ -2,9 +2,20 @@ import React, { FC, ReactNode } from 'react'; interface RenderConditionallyProps { shouldRender?: boolean; - children: ReactNode; + children?: ReactNode; + render?: () => ReactNode; backupChildren?: ReactNode; } -export const RenderConditionally: FC = ({ shouldRender, children, backupChildren = null }) => - shouldRender ? <>{children} : <>{backupChildren}; +export const RenderConditionally: FC = ({ + shouldRender, + children, + render, + backupChildren = null, +}) => { + if (render) { + return shouldRender ? <>{render()} : <>{backupChildren}; + } + + return shouldRender ? <>{children} : <>{backupChildren}; +}; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 52031f38..abb53c14 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -24,7 +24,7 @@ export class AlertGroupStore { rootStore: RootStore; alerts = new Map(); bulkActions: any = []; - silenceOptions: any; + silenceOptions: Array; searchResult: { [key: string]: Array } = {}; incidentFilters: any; initialQuery = qs.parse(window.location.search); diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 89b9f547..5f01d877 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -36,6 +36,7 @@ import { initErrorDataState, } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; import { PluginLink } from 'components/PluginLink/PluginLink'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { SourceCode } from 'components/SourceCode/SourceCode'; import { Text } from 'components/Text/Text'; import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge'; @@ -49,7 +50,8 @@ import { AlertGroupHelper } from 'models/alertgroup/alertgroup.helpers'; import { AlertAction, TimeLineItem, TimeLineRealm, GroupedAlert } from 'models/alertgroup/alertgroup.types'; import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; -import { IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown'; +import { CUSTOM_SILENCE_VALUE, IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown'; +import { IncidentSilenceModal } from 'pages/incidents/parts/IncidentSilenceModal'; import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; @@ -73,6 +75,7 @@ interface IncidentPageState extends PageBaseState { showAttachIncidentForm?: boolean; timelineFilter: string; resolutionNoteText: string; + silenceModalData: { incident: ApiSchemas['AlertGroup'] }; } @observer @@ -81,6 +84,7 @@ class _IncidentPage extends React.Component )} + + {/* Modal where users can input their custom duration for silencing an alert group */} + ( + this.setState({ silenceModalData: undefined })} + onSave={(duration: number) => { + this.setState({ silenceModalData: undefined }); + store.alertGroupStore.doIncidentAction(silenceModalData.incident.pk, AlertAction.Silence, duration); + }} + /> + )} + /> )} @@ -342,7 +363,7 @@ class _IncidentPage extends React.Component @@ -437,7 +458,7 @@ class _IncidentPage extends React.Component { + getSilenceClickHandler = (incident: ApiSchemas['AlertGroup']) => { const { store } = this.props; - return (value: number) => { - return store.alertGroupStore.doIncidentAction(incidentId, AlertAction.Silence, value); + return (value: number): Promise => { + if (value === CUSTOM_SILENCE_VALUE) { + this.setState({ silenceModalData: { incident } }); + return Promise.resolve(); // awaited by other component + } + return store.alertGroupStore.doIncidentAction(incident.pk, AlertAction.Silence, value); }; }; diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index cee71fa2..044984de 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -184,6 +184,7 @@ class _IncidentsPage extends React.Component + {showAddAlertGroupForm && ( { diff --git a/grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx b/grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx index d31399f0..fd2d0350 100644 --- a/grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx +++ b/grafana-plugin/src/pages/incidents/parts/IncidentDropdown.tsx @@ -1,7 +1,8 @@ import React, { FC, SyntheticEvent, useRef, useState } from 'react'; import { cx } from '@emotion/css'; -import { Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { intervalToAbbreviatedDurationString } from '@grafana/data'; +import { Icon, LoadingPlaceholder, Tooltip, useStyles2 } from '@grafana/ui'; import { getUtilStyles } from 'styles/utils.styles'; import { Tag, TagColor } from 'components/Tag/Tag'; @@ -13,6 +14,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types'; import { UserActions } from 'utils/authorization/authorization'; import { getIncidentDropdownStyles } from './IncidentDropdown.styles'; +import { IncidentSilenceModal } from './IncidentSilenceModal'; import { SilenceSelect } from './SilenceSelect'; const getIncidentTagColor = (alert: ApiSchemas['AlertGroup']) => { @@ -55,6 +57,8 @@ function IncidentStatusTag({ ); } +export const CUSTOM_SILENCE_VALUE = -100; + export const IncidentDropdown: FC<{ alert: ApiSchemas['AlertGroup']; onResolve: (e: SyntheticEvent) => Promise; @@ -67,6 +71,7 @@ export const IncidentDropdown: FC<{ const [isLoading, setIsLoading] = useState(false); const [currentLoadingAction, setCurrentActionLoading] = useState(undefined); const [forcedOpenAction, setForcedOpenAction] = useState(undefined); + const [isSilenceModalOpen, setIsSilenceModalOpen] = useState(false); const styles = useStyles2(getIncidentDropdownStyles); const utilStyles = useStyles2(getUtilStyles); @@ -160,60 +165,79 @@ export const IncidentDropdown: FC<{ if (alert.status === IncidentStatus.Firing) { return ( - ( -
- -
onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)} - > - Acknowledge{' '} - {currentLoadingAction === IncidentStatus.Acknowledged && isLoading && ( - - - - )} + <> + ( +
+ +
onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)} + > + Acknowledge{' '} + {currentLoadingAction === IncidentStatus.Acknowledged && isLoading && ( + + + + )} +
+
+ +
onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)} + > + Resolve{' '} + {currentLoadingAction === IncidentStatus.Resolved && isLoading && ( + + + + )} +
+
+ +
+ { + if (value === CUSTOM_SILENCE_VALUE) { + return setIsSilenceModalOpen(true); + } + + setIsLoading(true); + setForcedOpenAction(AlertAction.unResolve); + setCurrentActionLoading(IncidentStatus.Silenced); + + await onSilence(value); + + setIsLoading(false); + setForcedOpenAction(undefined); + setCurrentActionLoading(undefined); + }} + />
- - -
onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)} - > - Resolve{' '} - {currentLoadingAction === IncidentStatus.Resolved && isLoading && ( - - - - )} -
-
- -
- { - setIsLoading(true); - setForcedOpenAction(AlertAction.unResolve); - setCurrentActionLoading(IncidentStatus.Silenced); - - await onSilence(value); - - setIsLoading(false); - setForcedOpenAction(undefined); - setCurrentActionLoading(undefined); - }} - />
-
- )} - > - {({ openMenu }) => } -
+ )} + > + {({ openMenu }) => } + + setIsSilenceModalOpen(false)} + onSave={async (value) => { + setIsSilenceModalOpen(false); + setIsLoading(true); + await onSilence(value); + setIsLoading(false); + }} + /> + ); } @@ -265,7 +289,27 @@ export const IncidentDropdown: FC<{
)} > - {({ openMenu }) => } + {({ openMenu }) => ( + + + + + + )} ); }; + +function getSilencedTooltip(alert: ApiSchemas['AlertGroup']) { + if (alert.silenced_until === null) { + return `Silenced forever`; + } + return `Silence ends in ${getSilencedUntilInDuration(alert.silenced_until)}`; +} + +function getSilencedUntilInDuration(date: string) { + return intervalToAbbreviatedDurationString({ + start: new Date(), + end: new Date(date), + }); +} diff --git a/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx b/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx new file mode 100644 index 00000000..baf804ff --- /dev/null +++ b/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; + +import { css } from '@emotion/css'; +import { + DateTime, + addDurationToDate, + dateTime, + durationToMilliseconds, + intervalToAbbreviatedDurationString, + isValidDuration, + parseDuration, +} from '@grafana/data'; +import { Button, DateTimePicker, Field, HorizontalGroup, Input, Modal, useStyles2 } from '@grafana/ui'; + +import { useDebouncedCallback } from 'utils/hooks'; + +interface IncidentSilenceModalProps { + isOpen: boolean; + alertGroupID: string; + alertGroupName: string; + + onDismiss: () => void; + onSave: (value: number) => void; +} + +const IncidentSilenceModal: React.FC = ({ + isOpen, + alertGroupID, + alertGroupName, + + onDismiss, + onSave, +}) => { + const [date, setDate] = useState(dateTime('2021-05-05 12:00:00')); + const [duration, setDuration] = useState(''); + const debouncedUpdateDateTime = useDebouncedCallback(updateDateTime, 500); + + const styles = useStyles2(getStyles); + const isDurationValid = isValidDuration(duration); + + return ( + +
+ + + + + + + +
+ + + + + +
+ ); + + function onDateChange(date: DateTime) { + setDate(date); + const duration = intervalToAbbreviatedDurationString({ + start: new Date(), + end: new Date(date.toDate()), + }); + setDuration(duration); + } + + function onDurationChange(event: React.SyntheticEvent) { + const newDuration = event.currentTarget.value; + if (newDuration !== duration) { + setDuration(newDuration); + debouncedUpdateDateTime(newDuration); + } + } + + function updateDateTime(newDuration: string) { + setDate(dateTime(addDurationToDate(new Date(), parseDuration(newDuration)))); + } + + function onSubmit() { + onSave(durationToMilliseconds(parseDuration(duration)) / 1000); + } +}; + +const getStyles = () => ({ + root: css` + width: 600px; + `, + + container: css` + width: 100%; + display: flex; + column-gap: 16px; + `, + containerChild: css` + flex-grow: 1; + `, +}); + +export { IncidentSilenceModal }; diff --git a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx index abbb1360..89056dee 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ButtonCascader, ComponentSize } from '@grafana/ui'; +import { ButtonCascader, CascaderOption, ComponentSize } from '@grafana/ui'; import { observer } from 'mobx-react'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; @@ -8,6 +8,8 @@ import { SelectOption } from 'state/types'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; +import { CUSTOM_SILENCE_VALUE } from './IncidentDropdown'; + interface SilenceButtonCascaderProps { className?: string; disabled?: boolean; @@ -38,10 +40,15 @@ export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps
); - function getOptions() { - return silenceOptions.map((silenceOption: SelectOption) => ({ - value: silenceOption.value, - label: silenceOption.display_name, - })); + function getOptions(): CascaderOption[] { + return silenceOptions + .map((silenceOption: SelectOption) => ({ + value: silenceOption.value, + label: silenceOption.display_name, + })) + .concat({ + value: CUSTOM_SILENCE_VALUE, + label: 'Custom', + }) as CascaderOption[]; } }); diff --git a/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx b/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx index 8de2a75c..fd83a7dd 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx @@ -10,12 +10,13 @@ import { UserActions } from 'utils/authorization/authorization'; interface SilenceSelectProps { placeholder?: string; + customValueNum: number; onSelect: (value: number) => void; } export const SilenceSelect = observer((props: SilenceSelectProps) => { - const { placeholder = 'Silence for', onSelect } = props; + const { customValueNum, placeholder = 'Silence for', onSelect } = props; const store = useStore(); @@ -24,21 +25,31 @@ export const SilenceSelect = observer((props: SilenceSelectProps) => { const silenceOptions = alertGroupStore.silenceOptions || []; return ( - - { + onSelect(Number(value)); + }} + options={getOptions()} + /> + + ); function getOptions() { - return silenceOptions.map((silenceOption: SelectOption) => ({ - value: silenceOption.value, - label: silenceOption.display_name, - })); + return silenceOptions + .map((silenceOption: SelectOption) => ({ + value: silenceOption.value, + label: silenceOption.display_name, + })) + .concat({ + value: customValueNum, + label: 'Custom', + }); } }); diff --git a/grafana-plugin/src/styles/utils.styles.ts b/grafana-plugin/src/styles/utils.styles.ts index cdcc413b..fc84e0d8 100644 --- a/grafana-plugin/src/styles/utils.styles.ts +++ b/grafana-plugin/src/styles/utils.styles.ts @@ -4,6 +4,10 @@ import tinycolor from 'tinycolor2'; export const getUtilStyles = (theme: GrafanaTheme2) => { return { + flex: css` + display: flex; + `, + width100: css` width: 100%; `,