diff --git a/engine/apps/api/serializers/escalation_policy.py b/engine/apps/api/serializers/escalation_policy.py index 75f3628488..6accbc7125 100644 --- a/engine/apps/api/serializers/escalation_policy.py +++ b/engine/apps/api/serializers/escalation_policy.py @@ -1,3 +1,4 @@ +import time from datetime import timedelta from rest_framework import serializers @@ -8,7 +9,6 @@ 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,17 +47,15 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) queryset=User.objects, required=False, ) - wait_delay = DurationSecondsField( + wait_delay = serializers.ChoiceField( 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.IntegerField( + num_minutes_in_window = serializers.ChoiceField( 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, @@ -153,12 +151,29 @@ 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 67694042b3..e936d0daba 100644 --- a/engine/apps/api/serializers/user_notification_policy.py +++ b/engine/apps/api/serializers/user_notification_policy.py @@ -1,3 +1,4 @@ +import time from datetime import timedelta from rest_framework import serializers @@ -5,7 +6,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 DurationSecondsField, OrganizationFilteredPrimaryKeyRelatedField +from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import Forbidden from common.api_helpers.mixins import EagerLoadingMixin @@ -25,12 +26,6 @@ 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", @@ -46,6 +41,14 @@ 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 858252128e..cd0b8e4c4d 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -39,7 +39,7 @@ def test_create_escalation_policy(escalation_policy_internal_api_setup, make_use data = { "step": EscalationPolicy.STEP_WAIT, - "wait_delay": 60, + "wait_delay": "60.0", "escalation_chain": escalation_chain.public_primary_key, "notify_to_users_queue": [], "from_time": None, @@ -55,28 +55,6 @@ 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": int(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 @@ -712,7 +690,7 @@ def test_escalation_policy_can_not_create_with_non_step_type_related_data( "escalation_chain": escalation_chain.public_primary_key, "step": step, "notify_to_users_queue": [user.public_primary_key], - "wait_delay": 300, + "wait_delay": "300.0", "from_time": "06:50:00", "to_time": "04:10:00", } diff --git a/engine/apps/api/tests/test_user_notification_policy.py b/engine/apps/api/tests/test_user_notification_policy.py index 679ec52da1..996775cc93 100644 --- a/engine/apps/api/tests/test_user_notification_policy.py +++ b/engine/apps/api/tests/test_user_notification_policy.py @@ -1,5 +1,4 @@ import json -from datetime import timedelta from unittest.mock import patch import pytest @@ -68,26 +67,6 @@ 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": int(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 @@ -273,7 +252,7 @@ def test_unable_to_change_importance(user_notification_policy_internal_api_setup @pytest.mark.django_db -@pytest.mark.parametrize("wait_delay, expected_wait_delay", [(None, 300), (900, 900)]) +@pytest.mark.parametrize("wait_delay, expected_wait_delay", [(None, "300.0"), ("900.0", "900.0")]) def test_switch_step_type_from_notify_to_wait( make_organization_and_user_with_plugin_token, make_user_auth_headers, @@ -421,7 +400,9 @@ def test_switch_notification_channel( @pytest.mark.django_db -@pytest.mark.parametrize("from_wait_delay, to_wait_delay", [(None, 300), (timezone.timedelta(seconds=900), 900)]) +@pytest.mark.parametrize( + "from_wait_delay, to_wait_delay", [(None, "300.0"), (timezone.timedelta(seconds=900), "900.0")] +) def test_switch_wait_delay( make_organization_and_user_with_plugin_token, make_user_auth_headers, diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index f47f3ccfbf..57a10ecfd4 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -643,8 +643,6 @@ 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 945b634825..beda08d1dc 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -138,7 +138,6 @@ 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]}) @@ -146,7 +145,6 @@ 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 c7186f6dd8..3fefe10671 100644 --- a/engine/common/api_helpers/custom_fields.py +++ b/engine/common/api_helpers/custom_fields.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from django.core.exceptions import ObjectDoesNotExist from drf_spectacular.utils import extend_schema_field from rest_framework import fields, serializers @@ -207,12 +205,3 @@ def __init__(self, **kwargs): ], **kwargs, ) - - -# TODO: FloatField is used for backward-compatibility, change to IntegerField in a future release -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 int(value.total_seconds()) diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 952123840a..c977a767a7 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -1,9 +1,8 @@ import React, { ChangeEvent } from 'react'; import { cx } from '@emotion/css'; -import { SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, 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'; @@ -28,23 +27,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 { WithStoreProps } from 'state/types'; +import { SelectOption, 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; } -interface EscalationPolicyBaseProps { +export interface EscalationPolicyProps extends ElementSortableProps, Themeable2 { data: EscalationPolicyType; + waitDelays?: any[]; isDisabled?: boolean; + numMinutesInWindowOptions: SelectOption[]; channels?: any[]; onChange: (id: EscalationPolicyType['id'], value: Partial) => void; onDelete: (data: EscalationPolicyType) => void; @@ -53,31 +52,40 @@ interface EscalationPolicyBaseProps { 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, theme } = this.props; + const { data, escalationChoices, number, isDisabled, backgroundClassName, backgroundHexNumber } = this.props; const { id, step, is_final } = data; const escalationOption = escalationChoices.find( (escalationOption: EscalationPolicyOption) => escalationOption.value === step ); - const { textColor: itemTextColor } = getLabelBackgroundTextColorObject('green', theme); - const styles = getEscalationPolicyStyles(theme); + const { textColor: itemTextColor } = getLabelBackgroundTextColorObject('green', this.props.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 ( @@ -182,7 +191,7 @@ class _EscalationPolicy extends React.Component { displayField="username" valueField="pk" placeholder="Select Users" - className={cx(styles.select, styles.control, styles.multiSelect)} + className={cx(this.styles.select, this.styles.control, this.styles.multiSelect)} value={notify_to_users_queue} onChange={this.getOnChangeHandler('notify_to_users_queue')} getOptionLabel={({ value }: SelectableValue) => } @@ -197,15 +206,14 @@ class _EscalationPolicy extends React.Component { } renderImportance() { - const { data, isDisabled, theme } = this.props; + const { data, isDisabled } = this.props; const { important } = data; - const styles = getEscalationPolicyStyles(theme); return ( - this.getOnSelectChangeHandler('wait_delay')({ value: option.value * 60 }) - } - options={silenceOptions} + className={cx(this.styles.select, this.styles.control)} + // @ts-ignore + value={wait_delay} + onChange={this.getOnSelectChangeHandler('wait_delay')} + options={waitDelays.map((waitDelay: SelectOption) => ({ + value: waitDelay.value, + label: waitDelay.display_name, + }))} width={'auto'} - allowCustomValue - onCreateOption={(option) => this.onCreateOption('wait_delay', option, true)} /> ); } renderNumAlertsInWindow() { - const { data, isDisabled, theme } = this.props; + const { data, isDisabled } = this.props; const { num_alerts_in_window } = data; - const styles = getEscalationPolicyStyles(theme); return ( { @@ -319,16 +314,8 @@ class _EscalationPolicy extends React.Component { } renderNumMinutesInWindowOptions() { - const { data, isDisabled, theme } = this.props; + const { data, isDisabled, numMinutesInWindowOptions = [] } = 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 ( @@ -336,12 +323,14 @@ class _EscalationPolicy extends React.Component { menuShouldPortal disabled={isDisabled} placeholder="Period" - className={cx(styles.select, styles.control)} - value={num_minutes_in_window ? optionValue : undefined} + className={cx(this.styles.select, this.styles.control)} + // @ts-ignore + value={num_minutes_in_window} onChange={this.getOnSelectChangeHandler('num_minutes_in_window')} - allowCustomValue - onCreateOption={(option) => this.onCreateOption('num_minutes_in_window', option)} - options={options} + options={numMinutesInWindowOptions.map((waitDelay: SelectOption) => ({ + value: waitDelay.value, + label: waitDelay.display_name, + }))} /> ); @@ -350,12 +339,10 @@ class _EscalationPolicy extends React.Component { renderNotifySchedule() { const { data, - theme, isDisabled, store: { grafanaTeamStore, scheduleStore }, } = this.props; const { notify_schedule } = data; - const styles = getEscalationPolicyStyles(theme); return ( @@ -369,7 +356,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select Schedule" - className={cx(styles.select, styles.control)} + className={cx(this.styles.select, this.styles.control)} value={notify_schedule} onChange={this.getOnChangeHandler('notify_schedule')} getOptionLabel={(item: SelectableValue) => { @@ -389,13 +376,10 @@ class _EscalationPolicy extends React.Component { renderNotifyUserGroup() { const { data, - theme, isDisabled, store: { userGroupStore }, } = this.props; - const { notify_to_group } = data; - const styles = getEscalationPolicyStyles(theme); return ( @@ -409,7 +393,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select User Group" - className={cx(styles.select, styles.control)} + className={cx(this.styles.select, this.styles.control)} value={notify_to_group} onChange={this.getOnChangeHandler('notify_to_group')} width={'auto'} @@ -421,13 +405,10 @@ class _EscalationPolicy extends React.Component { renderTriggerCustomWebhook() { const { data, - theme, isDisabled, store: { grafanaTeamStore, outgoingWebhookStore }, } = this.props; - const { custom_webhook } = data; - const styles = getEscalationPolicyStyles(theme); return ( @@ -440,7 +421,7 @@ class _EscalationPolicy extends React.Component { displayField="name" valueField="id" placeholder="Select Webhook" - className={cx(styles.select, styles.control)} + className={cx(this.styles.select, this.styles.control)} value={custom_webhook} onChange={this.getOnChangeHandler('custom_webhook')} getOptionLabel={(item: SelectableValue) => { @@ -490,24 +471,6 @@ 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; @@ -573,5 +536,5 @@ class _EscalationPolicy extends React.Component { } export const EscalationPolicy = withMobXProviderContext( - SortableElement(withTheme2(_EscalationPolicy)) -) as unknown as React.ComponentClass; + SortableElement(withTheme2(_EscalationPolicy)) as React.ComponentClass +); diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index 68308dbe83..4e9a5d552a 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -3,7 +3,6 @@ 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'; @@ -16,10 +15,8 @@ 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 { @@ -185,51 +182,24 @@ export class NotificationPolicy extends React.Component delay.duration === waitDelayInMinutes) || { - value: waitDelayInMinutes, - label: waitDelayInMinutes, - }; - return ( -
- ({ + label: waitDelay.display_name, + value: waitDelay.value, + }))} + /> ); } @@ -328,17 +298,6 @@ 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 deleted file mode 100644 index 7f749666a9..0000000000 --- a/grafana-plugin/src/components/Policy/Policy.consts.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 f3e324262a..97ae2cd43f 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -4,6 +4,7 @@ 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'; @@ -50,6 +51,8 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps) useEffect(() => { escalationPolicyStore.updateWebEscalationPolicyOptions(); + escalationPolicyStore.updateEscalationPolicyOptions(); + escalationPolicyStore.updateNumMinutesInWindowOptions(); }, []); const handleSortEnd = useCallback( @@ -104,9 +107,14 @@ 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 8241213a50..abb53c14b3 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -24,6 +24,7 @@ export class AlertGroupStore { rootStore: RootStore; alerts = new Map(); bulkActions: any = []; + silenceOptions: Array; searchResult: { [key: string]: Array } = {}; incidentFilters: any; initialQuery = qs.parse(window.location.search); @@ -125,6 +126,14 @@ 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 20801ccda5..81b394ee84 100644 --- a/grafana-plugin/src/models/escalation_policy/escalation_policy.ts +++ b/grafana-plugin/src/models/escalation_policy/escalation_policy.ts @@ -1,3 +1,4 @@ +import { get } from 'lodash-es'; import { action, observable, makeObservable, runInAction } from 'mobx'; import { BaseStore } from 'models/base_store'; @@ -6,6 +7,7 @@ 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 @@ -19,6 +21,9 @@ export class EscalationPolicyStore extends BaseStore { @observable escalationChoices: any = []; + @observable + numMinutesInWindowOptions: SelectOption[] = []; + @observable webEscalationChoices: any = []; @@ -39,6 +44,26 @@ 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 d3c6bdd3a5..94c838f428 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -40,7 +40,6 @@ 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'; @@ -55,7 +54,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 { 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'; @@ -93,7 +92,11 @@ 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 1924e014c4..ed7d2cfb3d 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -148,6 +148,7 @@ class _IncidentsPage extends React.Component Promise; @@ -198,6 +199,7 @@ 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 c959d2663e..baf804ff72 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, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { DateTime, addDurationToDate, @@ -11,26 +11,18 @@ 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: number; + alertGroupID: string; alertGroupName: string; onDismiss: () => void; onSave: (value: number) => void; } -interface FormFields { - duration: string; -} - const IncidentSilenceModal: React.FC = ({ isOpen, alertGroupID, @@ -39,118 +31,66 @@ const IncidentSilenceModal: React.FC = ({ onDismiss, onSave, }) => { - const [date, setDate] = useState(dateTime()); + 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 utilStyles = useStyles2(getUtilStyles); - - const { - control, - setValue, - getValues, - handleSubmit, - formState: { errors }, - } = useForm({ - mode: 'onSubmit', - }); + const isDurationValid = isValidDuration(duration); return ( - Silence alert group #${alertGroupID} ${alertGroupName} - - } + title={`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 onFormSubmit() { - onSave(durationToMilliseconds(parseDuration(getValues('duration'))) / 1000); - } - - function onDateChange(newDate: DateTime) { + function onDateChange(date: DateTime) { + setDate(date); const duration = intervalToAbbreviatedDurationString({ start: new Date(), - end: new Date(newDate.toDate()), + end: new Date(date.toDate()), }); + setDuration(duration); + } - if (!duration) { - openWarningNotification('Silence Date is either invalid or in the past'); - } else { - setDate(newDate); - setValue('duration', 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 = () => ({ @@ -161,15 +101,10 @@ const getStyles = () => ({ container: css` width: 100%; display: flex; - column-gap: 8px; + column-gap: 16px; `, containerChild: css` - flex-basis: 50%; - `, - datePicker: css` - label { - display: none; - } + flex-grow: 1; `, }); diff --git a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx index 7b1b2064c1..89056dee86 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceButtonCascader.tsx @@ -3,10 +3,13 @@ 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; @@ -17,6 +20,9 @@ interface SilenceButtonCascaderProps { export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps) => { const { onSelect, className, disabled = false, buttonSize } = props; + const { alertGroupStore } = useStore(); + + const silenceOptions = alertGroupStore.silenceOptions || []; return ( @@ -35,6 +41,14 @@ export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps ); function getOptions(): CascaderOption[] { - return [...SILENCE_DURATION_LIST] as 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 f78a2e78b5..fd83a7dd47 100644 --- a/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx +++ b/grafana-plugin/src/pages/incidents/parts/SilenceSelect.tsx @@ -3,18 +3,26 @@ 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 { placeholder = 'Silence for', onSelect } = props; + const { customValueNum, placeholder = 'Silence for', onSelect } = props; + + const store = useStore(); + + const { alertGroupStore } = store; + + const silenceOptions = alertGroupStore.silenceOptions || []; return ( <> @@ -34,6 +42,14 @@ export const SilenceSelect = observer((props: SilenceSelectProps) => { ); function getOptions() { - return [...SILENCE_DURATION_LIST]; + return silenceOptions + .map((silenceOption: SelectOption) => ({ + value: silenceOption.value, + label: silenceOption.display_name, + })) + .concat({ + value: customValueNum, + label: 'Custom', + }); } });