diff --git a/engine/apps/api/serializers/escalation_policy.py b/engine/apps/api/serializers/escalation_policy.py index 6accbc7125..75f3628488 100644 --- a/engine/apps/api/serializers/escalation_policy.py +++ b/engine/apps/api/serializers/escalation_policy.py @@ -1,4 +1,3 @@ -import time from datetime import timedelta from rest_framework import serializers @@ -9,6 +8,7 @@ from apps.user_management.models import Team, User from apps.webhooks.models import Webhook from common.api_helpers.custom_fields import ( + DurationSecondsField, OrganizationFilteredPrimaryKeyRelatedField, UsersFilteredByOrganizationField, ) @@ -47,15 +47,17 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) queryset=User.objects, required=False, ) - wait_delay = serializers.ChoiceField( + wait_delay = DurationSecondsField( required=False, - choices=EscalationPolicy.WEB_DURATION_CHOICES, allow_null=True, + min_value=timedelta(minutes=1), + max_value=timedelta(hours=24), ) - num_minutes_in_window = serializers.ChoiceField( + num_minutes_in_window = serializers.IntegerField( required=False, - choices=EscalationPolicy.WEB_DURATION_CHOICES_MINUTES, allow_null=True, + min_value=1, # 1 minute + max_value=24 * 60, # 24 hours ) notify_schedule = OrganizationFilteredPrimaryKeyRelatedField( queryset=OnCallSchedule.objects, @@ -151,29 +153,12 @@ def validate_step(self, step_type): raise serializers.ValidationError("Invalid escalation step type: step is Slack-specific") return step_type - def to_internal_value(self, data): - data = self._wait_delay_to_internal_value(data) - return super().to_internal_value(data) - def to_representation(self, instance): step = instance.step result = super().to_representation(instance) result = EscalationPolicySerializer._get_important_field(step, result) return result - @staticmethod - def _wait_delay_to_internal_value(data): - if data.get(WAIT_DELAY, None): - try: - time.strptime(data[WAIT_DELAY], "%H:%M:%S") - except ValueError: - try: - data[WAIT_DELAY] = str(timedelta(seconds=float(data[WAIT_DELAY]))) - except ValueError: - raise serializers.ValidationError("Invalid wait delay format") - - return data - @staticmethod def _get_important_field(step, result): if step in {*EscalationPolicy.DEFAULT_STEPS_SET, *EscalationPolicy.STEPS_WITH_NO_IMPORTANT_VERSION_SET}: diff --git a/engine/apps/api/serializers/user_notification_policy.py b/engine/apps/api/serializers/user_notification_policy.py index e936d0daba..67694042b3 100644 --- a/engine/apps/api/serializers/user_notification_policy.py +++ b/engine/apps/api/serializers/user_notification_policy.py @@ -1,4 +1,3 @@ -import time from datetime import timedelta from rest_framework import serializers @@ -6,7 +5,7 @@ from apps.base.models import UserNotificationPolicy from apps.base.models.user_notification_policy import NotificationChannelAPIOptions from apps.user_management.models import User -from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField +from common.api_helpers.custom_fields import DurationSecondsField, OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import Forbidden from common.api_helpers.mixins import EagerLoadingMixin @@ -26,6 +25,12 @@ class UserNotificationPolicyBaseSerializer(EagerLoadingMixin, serializers.ModelS default=UserNotificationPolicy.Step.NOTIFY, choices=UserNotificationPolicy.Step.choices, ) + wait_delay = DurationSecondsField( + required=False, + allow_null=True, + min_value=timedelta(minutes=1), + max_value=timedelta(hours=24), + ) SELECT_RELATED = [ "user", @@ -41,14 +46,6 @@ class Meta: read_only_fields = ["order"] def to_internal_value(self, data): - if data.get("wait_delay", None): - try: - time.strptime(data["wait_delay"], "%H:%M:%S") - except ValueError: - try: - data["wait_delay"] = str(timedelta(seconds=float(data["wait_delay"]))) - except ValueError: - raise serializers.ValidationError("Invalid wait delay format") data = self._notify_by_to_internal_value(data) return super().to_internal_value(data) diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index cd0b8e4c4d..e6908d7a0f 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -55,6 +55,28 @@ def test_create_escalation_policy(escalation_policy_internal_api_setup, make_use assert EscalationPolicy.objects.get(public_primary_key=response.data["id"]).order == max_order + 1 +@pytest.mark.django_db +@pytest.mark.parametrize("wait_delay", (timedelta(seconds=59), timedelta(hours=24, seconds=1))) +def test_create_escalation_policy_wait_delay_invalid( + escalation_policy_internal_api_setup, make_user_auth_headers, wait_delay +): + token, escalation_chain, _, user, _ = escalation_policy_internal_api_setup + client = APIClient() + url = reverse("api-internal:escalation_policy-list") + + data = { + "step": EscalationPolicy.STEP_WAIT, + "wait_delay": str(wait_delay.total_seconds()), + "escalation_chain": escalation_chain.public_primary_key, + "notify_to_users_queue": [], + "from_time": None, + "to_time": None, + } + + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db def test_create_escalation_policy_webhook( escalation_policy_internal_api_setup, make_custom_webhook, make_user_auth_headers diff --git a/engine/apps/api/tests/test_user_notification_policy.py b/engine/apps/api/tests/test_user_notification_policy.py index 996775cc93..e94551b079 100644 --- a/engine/apps/api/tests/test_user_notification_policy.py +++ b/engine/apps/api/tests/test_user_notification_policy.py @@ -1,4 +1,5 @@ import json +from datetime import timedelta from unittest.mock import patch import pytest @@ -67,6 +68,26 @@ def test_create_notification_policy(user_notification_policy_internal_api_setup, assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.django_db +@pytest.mark.parametrize("wait_delay", (timedelta(seconds=59), timedelta(hours=24, seconds=1))) +def test_create_notification_policy_wait_delay_invalid( + user_notification_policy_internal_api_setup, make_user_auth_headers, wait_delay +): + token, _, users = user_notification_policy_internal_api_setup + admin, _ = users + client = APIClient() + url = reverse("api-internal:notification_policy-list") + + data = { + "step": UserNotificationPolicy.Step.WAIT, + "wait_delay": str(wait_delay.total_seconds()), + "important": False, + "user": admin.public_primary_key, + } + response = client.post(url, data, format="json", **make_user_auth_headers(admin, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db def test_admin_can_create_notification_policy_for_user( user_notification_policy_internal_api_setup, make_user_auth_headers diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 57a10ecfd4..f47f3ccfbf 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -643,6 +643,8 @@ def silence(self, request, pk=None): ) @action(methods=["get"], detail=False) def silence_options(self, request): + # TODO: DEPRECATED, REMOVE IN A FUTURE RELEASE + """ Retrieve a list of valid silence options """ diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index beda08d1dc..945b634825 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -138,6 +138,7 @@ def escalation_options(self, request): @action(detail=False, methods=["get"]) def delay_options(self, request): + # TODO: DEPRECATED, REMOVE IN A FUTURE RELEASE choices = [] for item in EscalationPolicy.WEB_DURATION_CHOICES: choices.append({"value": str(item[0]), "sec_value": item[0], "display_name": item[1]}) @@ -145,6 +146,7 @@ def delay_options(self, request): @action(detail=False, methods=["get"]) def num_minutes_in_window_options(self, request): + # TODO: DEPRECATED, REMOVE IN A FUTURE RELEASE choices = [ {"value": choice[0], "display_name": choice[1]} for choice in EscalationPolicy.WEB_DURATION_CHOICES_MINUTES ] diff --git a/engine/common/api_helpers/custom_fields.py b/engine/common/api_helpers/custom_fields.py index 3fefe10671..d2b82bab5b 100644 --- a/engine/common/api_helpers/custom_fields.py +++ b/engine/common/api_helpers/custom_fields.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.core.exceptions import ObjectDoesNotExist from drf_spectacular.utils import extend_schema_field from rest_framework import fields, serializers @@ -205,3 +207,11 @@ def __init__(self, **kwargs): ], **kwargs, ) + + +class DurationSecondsField(serializers.FloatField): + def to_internal_value(self, data): + return timedelta(seconds=int(super().to_internal_value(data))) + + def to_representation(self, value): + return str(value.total_seconds()) diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index c977a767a7..952123840a 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -1,8 +1,9 @@ import React, { ChangeEvent } from 'react'; import { cx } from '@emotion/css'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { SelectableValue } from '@grafana/data'; import { Button, Input, Select, IconButton, withTheme2, Themeable2 } from '@grafana/ui'; +import { isNumber } from 'lodash-es'; import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import { SortableElement } from 'react-sortable-hoc'; @@ -27,23 +28,23 @@ import { Schedule } from 'models/schedule/schedule.types'; import { UserHelper } from 'models/user/user.helpers'; import { UserGroup } from 'models/user_group/user_group.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; -import { SelectOption, WithStoreProps } from 'state/types'; +import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { UserActions } from 'utils/authorization/authorization'; +import { openWarningNotification } from 'utils/utils'; import { DragHandle } from './DragHandle'; import { getEscalationPolicyStyles } from './EscalationPolicy.styles'; +import { POLICY_DURATION_LIST_MINUTES } from './Policy.consts'; import { PolicyNote } from './PolicyNote'; interface ElementSortableProps extends WithStoreProps { index: number; } -export interface EscalationPolicyProps extends ElementSortableProps, Themeable2 { +interface EscalationPolicyBaseProps { data: EscalationPolicyType; - waitDelays?: any[]; isDisabled?: boolean; - numMinutesInWindowOptions: SelectOption[]; channels?: any[]; onChange: (id: EscalationPolicyType['id'], value: Partial) => void; onDelete: (data: EscalationPolicyType) => void; @@ -52,40 +53,31 @@ export interface EscalationPolicyProps extends ElementSortableProps, Themeable2 backgroundClassName?: string; backgroundHexNumber?: string; isSlackInstalled: boolean; - theme: GrafanaTheme2; } +// We export the base props class, the actual definition is wrapped by MobX +// MobX adds extra props that we do not need to pass on the consuming side +export interface EscalationPolicyProps extends EscalationPolicyBaseProps, ElementSortableProps, Themeable2 {} + @observer class _EscalationPolicy extends React.Component { private styles: ReturnType; - constructor(props: EscalationPolicyProps) { - super(props); - this.styles = getEscalationPolicyStyles(props.theme); - } - - componentDidUpdate(prevProps: Readonly): void { - if (prevProps.theme !== this.props.theme) { - // fetch new styles whenever the theme changes - this.styles = getEscalationPolicyStyles(this.props.theme); - this.forceUpdate(); - } - } - render() { - const { data, escalationChoices, number, isDisabled, backgroundClassName, backgroundHexNumber } = this.props; + const { data, escalationChoices, number, isDisabled, backgroundClassName, backgroundHexNumber, theme } = this.props; const { id, step, is_final } = data; const escalationOption = escalationChoices.find( (escalationOption: EscalationPolicyOption) => escalationOption.value === step ); - const { textColor: itemTextColor } = getLabelBackgroundTextColorObject('green', this.props.theme); + const { textColor: itemTextColor } = getLabelBackgroundTextColorObject('green', theme); + const styles = getEscalationPolicyStyles(theme); return ( { reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)} {this.renderNote()} {is_final || isDisabled ? null : ( - + { const { data, isDisabled, + theme, store: { userStore }, } = this.props; const { notify_to_users_queue } = data; + const styles = getEscalationPolicyStyles(theme); return ( @@ -191,7 +182,7 @@ class _EscalationPolicy extends React.Component { displayField="username" valueField="pk" placeholder="Select Users" - className={cx(this.styles.select, this.styles.control, this.styles.multiSelect)} + className={cx(styles.select, styles.control, styles.multiSelect)} value={notify_to_users_queue} onChange={this.getOnChangeHandler('notify_to_users_queue')} getOptionLabel={({ value }: SelectableValue) => } @@ -206,14 +197,15 @@ class _EscalationPolicy extends React.Component { } renderImportance() { - const { data, isDisabled } = this.props; + const { data, isDisabled, theme } = this.props; const { important } = data; + const styles = getEscalationPolicyStyles(theme); return ( ({ - value: waitDelay.value, - label: waitDelay.display_name, - }))} + className={cx(styles.select, styles.control)} + value={waitDelayInSeconds ? waitDelayOptionItem : undefined} + onChange={(option: SelectableValue) => + this.getOnSelectChangeHandler('wait_delay')({ value: option.value * 60 }) + } + options={silenceOptions} width={'auto'} + allowCustomValue + onCreateOption={(option) => this.onCreateOption('wait_delay', option, true)} /> ); } renderNumAlertsInWindow() { - const { data, isDisabled } = this.props; + const { data, isDisabled, theme } = this.props; const { num_alerts_in_window } = data; + const styles = getEscalationPolicyStyles(theme); return ( { @@ -314,8 +319,16 @@ class _EscalationPolicy extends React.Component { } renderNumMinutesInWindowOptions() { - const { data, isDisabled, numMinutesInWindowOptions = [] } = this.props; + const { data, isDisabled, theme } = this.props; const { num_minutes_in_window } = data; + const styles = getEscalationPolicyStyles(theme); + + const options: SelectableValue[] = [...POLICY_DURATION_LIST_MINUTES]; + + const optionValue = options.find((opt) => opt.value === num_minutes_in_window) || { + value: num_minutes_in_window, + label: num_minutes_in_window, + }; // either find it in the list or initialize it to show in the dropdown return ( @@ -323,14 +336,12 @@ class _EscalationPolicy extends React.Component { menuShouldPortal disabled={isDisabled} placeholder="Period" - className={cx(this.styles.select, this.styles.control)} - // @ts-ignore - value={num_minutes_in_window} + className={cx(styles.select, styles.control)} + value={num_minutes_in_window ? optionValue : undefined} onChange={this.getOnSelectChangeHandler('num_minutes_in_window')} - options={numMinutesInWindowOptions.map((waitDelay: SelectOption) => ({ - value: waitDelay.value, - label: waitDelay.display_name, - }))} + allowCustomValue + onCreateOption={(option) => this.onCreateOption('num_minutes_in_window', option)} + options={options} /> ); @@ -339,10 +350,12 @@ class _EscalationPolicy extends React.Component { renderNotifySchedule() { const { data, + theme, isDisabled, store: { grafanaTeamStore, scheduleStore }, } = this.props; const { notify_schedule } = data; + const styles = getEscalationPolicyStyles(theme); return ( @@ -356,7 +369,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select Schedule" - className={cx(this.styles.select, this.styles.control)} + className={cx(styles.select, styles.control)} value={notify_schedule} onChange={this.getOnChangeHandler('notify_schedule')} getOptionLabel={(item: SelectableValue) => { @@ -376,10 +389,13 @@ class _EscalationPolicy extends React.Component { renderNotifyUserGroup() { const { data, + theme, isDisabled, store: { userGroupStore }, } = this.props; + const { notify_to_group } = data; + const styles = getEscalationPolicyStyles(theme); return ( @@ -393,7 +409,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select User Group" - className={cx(this.styles.select, this.styles.control)} + className={cx(styles.select, styles.control)} value={notify_to_group} onChange={this.getOnChangeHandler('notify_to_group')} width={'auto'} @@ -405,10 +421,13 @@ class _EscalationPolicy extends React.Component { renderTriggerCustomWebhook() { const { data, + theme, isDisabled, store: { grafanaTeamStore, outgoingWebhookStore }, } = this.props; + const { custom_webhook } = data; + const styles = getEscalationPolicyStyles(theme); return ( @@ -421,7 +440,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select Webhook" - className={cx(this.styles.select, this.styles.control)} + className={cx(styles.select, styles.control)} value={custom_webhook} onChange={this.getOnChangeHandler('custom_webhook')} getOptionLabel={(item: SelectableValue) => { @@ -471,6 +490,24 @@ class _EscalationPolicy extends React.Component { ); } + onCreateOption = (fieldName: string, option: string, parseToSeconds = false) => { + if (!isNumber(+option)) { + return; + } + + const num = parseFloat(option); + + if (!Number.isInteger(+option)) { + return openWarningNotification('Given number must be an integer'); + } + + if (num < 1 || num > 24 * 60) { + return openWarningNotification('Given number must be in the range of 1 minute and 24 hours'); + } + + this.getOnSelectChangeHandler(fieldName)({ value: num * (parseToSeconds ? 60 : 1) }); + }; + getOnSelectChangeHandler = (field: string) => { return (option: SelectableValue) => { const { data, onChange = () => {} } = this.props; @@ -536,5 +573,5 @@ class _EscalationPolicy extends React.Component { } export const EscalationPolicy = withMobXProviderContext( - SortableElement(withTheme2(_EscalationPolicy)) as React.ComponentClass -); + SortableElement(withTheme2(_EscalationPolicy)) +) as unknown as React.ComponentClass; diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index 4e9a5d552a..68308dbe83 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Button, IconButton, Select, Themeable2, withTheme2 } from '@grafana/ui'; +import { isNumber } from 'lodash'; import { SortableElement } from 'react-sortable-hoc'; import { PluginLink } from 'components/PluginLink/PluginLink'; @@ -15,8 +16,10 @@ import { AppFeature } from 'state/features'; import { RootStore } from 'state/rootStore'; import { SelectOption } from 'state/types'; import { UserAction } from 'utils/authorization/authorization'; +import { openWarningNotification } from 'utils/utils'; import { DragHandle } from './DragHandle'; +import { POLICY_DURATION_LIST_MINUTES, POLICY_DURATION_LIST_SECONDS } from './Policy.consts'; import { PolicyNote } from './PolicyNote'; export interface NotificationPolicyProps extends Themeable2 { @@ -182,24 +185,51 @@ export class NotificationPolicy extends React.Component delay.duration === waitDelayInMinutes) || { + value: waitDelayInMinutes, + label: waitDelayInMinutes, + }; + return ( - this._getOnChangeHandler('wait_delay')({ value: option.value * 60 })} + options={optionsList} + allowCustomValue + onCreateOption={(option: string) => { + if (!isNumber(+option)) { + return; + } + + const num = parseFloat(option); + + if (!Number.isInteger(+option)) { + return openWarningNotification('Given number must be an integer'); + } + + if (num < 1 || num > 24 * 60) { + return openWarningNotification('Given number must be in the range of 1 minute and 24 hours'); + } + + this._getOnChangeHandler('wait_delay')({ value: num * 60 }); + }} + /> + minute(s) + ); } @@ -298,6 +328,17 @@ const getStyles = (_theme: GrafanaTheme2) => { width: 200px !important; flex-shrink: 0; `, + + delay: css` + width: 100px !important; + `, + + container: css` + width: 200px; + display: flex; + align-items: center; + margin-right: 12px; + `, }; }; diff --git a/grafana-plugin/src/components/Policy/Policy.consts.ts b/grafana-plugin/src/components/Policy/Policy.consts.ts new file mode 100644 index 0000000000..7f749666a9 --- /dev/null +++ b/grafana-plugin/src/components/Policy/Policy.consts.ts @@ -0,0 +1,46 @@ +import { SelectableValue } from '@grafana/data'; + +const POLICY_DURATION_LIST: SelectableValue[] = [ + { + value: 1, + label: '1', + }, + { + value: 5, + label: '5', + }, + { + value: 15, + label: '15', + }, + { + value: 30, + label: '30', + }, + { + value: 60, + label: '60', + }, +]; + +// SECONDS +export const POLICY_DURATION_LIST_SECONDS: SelectableValue[] = POLICY_DURATION_LIST.map((item: SelectableValue) => ({ + value: item.value * 60, + label: item.label, +})); + +// MINUTES +export const POLICY_DURATION_LIST_MINUTES: SelectableValue[] = [...POLICY_DURATION_LIST]; + +export const CUSTOM_SILENCE_VALUE = -100; + +export const SILENCE_DURATION_LIST: SelectableValue[] = [ + { value: CUSTOM_SILENCE_VALUE, label: 'Custom' }, + { value: 30 * 60, label: '30 minutes' }, + { value: 1 * 60 * 60, label: '1 hour' }, + { value: 2 * 60 * 60, label: '2 hours' }, + { value: 6 * 60 * 60, label: '6 hours' }, + { value: 12 * 60 * 60, label: '12 hours' }, + { value: 24 * 60 * 60, label: '24 hours' }, + { value: -1, label: 'Forever' }, +]; diff --git a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx index 97ae2cd43f..f3e324262a 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -4,7 +4,6 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { LoadingPlaceholder, Select, useStyles2, useTheme2 } from '@grafana/ui'; import cn from 'classnames/bind'; -import { get } from 'lodash-es'; import { observer } from 'mobx-react'; import { getLabelBackgroundTextColorObject } from 'styles/utils.styles'; @@ -51,8 +50,6 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps) useEffect(() => { escalationPolicyStore.updateWebEscalationPolicyOptions(); - escalationPolicyStore.updateEscalationPolicyOptions(); - escalationPolicyStore.updateNumMinutesInWindowOptions(); }, []); const handleSortEnd = useCallback( @@ -107,14 +104,9 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps) data={escalationPolicy} number={index + offset + 1} escalationChoices={escalationPolicyStore.webEscalationChoices} - waitDelays={get(escalationPolicyStore.escalationChoices, 'wait_delay.choices', [])} - numMinutesInWindowOptions={escalationPolicyStore.numMinutesInWindowOptions} onChange={escalationPolicyStore.saveEscalationPolicy.bind(escalationPolicyStore)} onDelete={escalationPolicyStore.deleteEscalationPolicy.bind(escalationPolicyStore)} isSlackInstalled={isSlackInstalled} - teamStore={store.grafanaTeamStore} - scheduleStore={store.scheduleStore} - outgoingWebhookStore={store.outgoingWebhookStore} isDisabled={isDisabled} {...extraProps} /> diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index abb53c14b3..8241213a50 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -24,7 +24,6 @@ export class AlertGroupStore { rootStore: RootStore; alerts = new Map(); bulkActions: any = []; - silenceOptions: Array; searchResult: { [key: string]: Array } = {}; incidentFilters: any; initialQuery = qs.parse(window.location.search); @@ -126,14 +125,6 @@ export class AlertGroupStore { this.setLiveUpdatesPaused(false); } - async fetchSilenceOptions() { - const { data } = await onCallApi().GET('/alertgroups/silence_options/', undefined); - - runInAction(() => { - this.silenceOptions = data; - }); - } - @AutoLoadingState(ActionKey.RESET_COLUMNS_FROM_ALERT_GROUP) @WithGlobalNotification({ success: 'Columns list has been reset' }) async resetColumns() { diff --git a/grafana-plugin/src/models/escalation_policy/escalation_policy.ts b/grafana-plugin/src/models/escalation_policy/escalation_policy.ts index 81b394ee84..20801ccda5 100644 --- a/grafana-plugin/src/models/escalation_policy/escalation_policy.ts +++ b/grafana-plugin/src/models/escalation_policy/escalation_policy.ts @@ -1,4 +1,3 @@ -import { get } from 'lodash-es'; import { action, observable, makeObservable, runInAction } from 'mobx'; import { BaseStore } from 'models/base_store'; @@ -7,7 +6,6 @@ import { EscalationPolicy } from 'models/escalation_policy/escalation_policy.typ import { makeRequest } from 'network/network'; import { move } from 'state/helpers'; import { RootStore } from 'state/rootStore'; -import { SelectOption } from 'state/types'; export class EscalationPolicyStore extends BaseStore { @observable.shallow @@ -21,9 +19,6 @@ export class EscalationPolicyStore extends BaseStore { @observable escalationChoices: any = []; - @observable - numMinutesInWindowOptions: SelectOption[] = []; - @observable webEscalationChoices: any = []; @@ -44,26 +39,6 @@ export class EscalationPolicyStore extends BaseStore { }); } - @action.bound - async updateEscalationPolicyOptions() { - const response = await makeRequest('/escalation_policies/', { - method: 'OPTIONS', - }); - - runInAction(() => { - this.escalationChoices = get(response, 'actions.POST', []); - }); - } - - @action.bound - async updateNumMinutesInWindowOptions() { - const response = await makeRequest('/escalation_policies/num_minutes_in_window_options/', {}); - - runInAction(() => { - this.numMinutesInWindowOptions = response; - }); - } - @action.bound async updateEscalationPolicies(escalationChainId: EscalationChain['id']) { const response = await makeRequest(this.path, { diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 94c838f428..d3c6bdd3a5 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -40,6 +40,7 @@ import { initErrorDataState, } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; import { PluginLink } from 'components/PluginLink/PluginLink'; +import { CUSTOM_SILENCE_VALUE } from 'components/Policy/Policy.consts'; import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { SourceCode } from 'components/SourceCode/SourceCode'; import { Text } from 'components/Text/Text'; @@ -54,7 +55,7 @@ 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 { CUSTOM_SILENCE_VALUE, IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown'; +import { IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown'; import { IncidentSilenceModal } from 'pages/incidents/parts/IncidentSilenceModal'; import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; @@ -92,11 +93,7 @@ class _IncidentPage extends React.Component ( this.setState({ silenceModalData: undefined })} diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index ed7d2cfb3d..1924e014c4 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -148,7 +148,6 @@ class _IncidentsPage extends React.Component Promise; @@ -199,7 +198,6 @@ export const IncidentDropdown: FC<{
} setIsSilenceModalOpen(false)} diff --git a/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx b/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx index baf804ff72..c959d2663e 100644 --- a/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx +++ b/grafana-plugin/src/pages/incidents/parts/IncidentSilenceModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { DateTime, addDurationToDate, @@ -11,18 +11,26 @@ import { parseDuration, } from '@grafana/data'; import { Button, DateTimePicker, Field, HorizontalGroup, Input, Modal, useStyles2 } from '@grafana/ui'; +import { Controller, useForm } from 'react-hook-form'; +import { bem, getUtilStyles } from 'styles/utils.styles'; +import { Text } from 'components/Text/Text'; import { useDebouncedCallback } from 'utils/hooks'; +import { openWarningNotification } from 'utils/utils'; interface IncidentSilenceModalProps { isOpen: boolean; - alertGroupID: string; + alertGroupID: number; alertGroupName: string; onDismiss: () => void; onSave: (value: number) => void; } +interface FormFields { + duration: string; +} + const IncidentSilenceModal: React.FC = ({ isOpen, alertGroupID, @@ -31,66 +39,118 @@ const IncidentSilenceModal: React.FC = ({ onDismiss, onSave, }) => { - const [date, setDate] = useState(dateTime('2021-05-05 12:00:00')); - const [duration, setDuration] = useState(''); + const [date, setDate] = useState(dateTime()); const debouncedUpdateDateTime = useDebouncedCallback(updateDateTime, 500); const styles = useStyles2(getStyles); - const isDurationValid = isValidDuration(duration); + const utilStyles = useStyles2(getUtilStyles); + + const { + control, + setValue, + getValues, + handleSubmit, + formState: { errors }, + } = useForm({ + mode: 'onSubmit', + }); return ( + Silence alert group #${alertGroupID} ${alertGroupName} + + } className={styles.root} > -
- - - - - - - -
- - - - - +
+
+ +
+ +
+
+ + { + return value?.trim() && isValidDuration(value) ? true : 'Duration is invalid'; + }, + }} + render={({ field }) => ( + + ) => { + const newDuration: string = event.currentTarget.value; + field.onChange(newDuration); + + debouncedUpdateDateTime(newDuration); + }} + placeholder="Enter duration (2h 30m)" + /> + + )} + /> +
+ + + + + +
); - function onDateChange(date: DateTime) { - setDate(date); + function onFormSubmit() { + onSave(durationToMilliseconds(parseDuration(getValues('duration'))) / 1000); + } + + function onDateChange(newDate: DateTime) { const duration = intervalToAbbreviatedDurationString({ start: new Date(), - end: new Date(date.toDate()), + end: new Date(newDate.toDate()), }); - setDuration(duration); - } - function onDurationChange(event: React.SyntheticEvent) { - const newDuration = event.currentTarget.value; - if (newDuration !== duration) { - setDuration(newDuration); - debouncedUpdateDateTime(newDuration); + if (!duration) { + openWarningNotification('Silence Date is either invalid or in the past'); + } else { + setDate(newDate); + setValue('duration', duration); } } function updateDateTime(newDuration: string) { setDate(dateTime(addDurationToDate(new Date(), parseDuration(newDuration)))); } - - function onSubmit() { - onSave(durationToMilliseconds(parseDuration(duration)) / 1000); - } }; const getStyles = () => ({ @@ -101,10 +161,15 @@ const getStyles = () => ({ container: css` width: 100%; display: flex; - column-gap: 16px; + column-gap: 8px; `, containerChild: css` - flex-grow: 1; + flex-basis: 50%; + `, + datePicker: css` + label { + display: none; + } `, }); diff --git a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx index 89056dee86..7b1b2064c1 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx @@ -3,13 +3,10 @@ import React from 'react'; import { ButtonCascader, CascaderOption, ComponentSize } from '@grafana/ui'; import { observer } from 'mobx-react'; +import { SILENCE_DURATION_LIST } from 'components/Policy/Policy.consts'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -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; @@ -20,9 +17,6 @@ interface SilenceButtonCascaderProps { export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps) => { const { onSelect, className, disabled = false, buttonSize } = props; - const { alertGroupStore } = useStore(); - - const silenceOptions = alertGroupStore.silenceOptions || []; return ( @@ -41,14 +35,6 @@ export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps ); function getOptions(): CascaderOption[] { - return silenceOptions - .map((silenceOption: SelectOption) => ({ - value: silenceOption.value, - label: silenceOption.display_name, - })) - .concat({ - value: CUSTOM_SILENCE_VALUE, - label: 'Custom', - }) as CascaderOption[]; + return [...SILENCE_DURATION_LIST] as CascaderOption[]; } }); diff --git a/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx b/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx index fd83a7dd47..f78a2e78b5 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx @@ -3,26 +3,18 @@ import React from 'react'; import { Select } from '@grafana/ui'; import { observer } from 'mobx-react'; +import { SILENCE_DURATION_LIST } from 'components/Policy/Policy.consts'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { SelectOption } from 'state/types'; -import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; interface SilenceSelectProps { placeholder?: string; - customValueNum: number; onSelect: (value: number) => void; } export const SilenceSelect = observer((props: SilenceSelectProps) => { - const { customValueNum, placeholder = 'Silence for', onSelect } = props; - - const store = useStore(); - - const { alertGroupStore } = store; - - const silenceOptions = alertGroupStore.silenceOptions || []; + const { placeholder = 'Silence for', onSelect } = props; return ( <> @@ -42,14 +34,6 @@ export const SilenceSelect = observer((props: SilenceSelectProps) => { ); function getOptions() { - return silenceOptions - .map((silenceOption: SelectOption) => ({ - value: silenceOption.value, - label: silenceOption.display_name, - })) - .concat({ - value: customValueNum, - label: 'Custom', - }); + return [...SILENCE_DURATION_LIST]; } });