Allow custom wait delays (#4422)

# What this PR does

Allows custom wait durations for:
* `Wait` escalation policy
* `>X alerts per Y minutes` escalation policy
* `Wait` user notification policy

## Which issue(s) this PR closes

Related to https://github.com/grafana/oncall/issues/2464

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.

---------

Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Vadim Stepanov 2024-05-31 14:18:59 +01:00 committed by GitHub
parent f7975f1280
commit d8e1a1dfae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 385 additions and 236 deletions

View file

@ -1,4 +1,3 @@
import time
from datetime import timedelta
from rest_framework import serializers
@ -9,6 +8,7 @@ from apps.slack.models import SlackUserGroup
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 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
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}:

View file

@ -1,4 +1,3 @@
import time
from datetime import timedelta
from rest_framework import serializers
@ -6,7 +5,7 @@ from rest_framework import serializers
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 UserNotificationPolicyBaseSerializer(EagerLoadingMixin, serializers.ModelS
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)

View file

@ -39,7 +39,7 @@ def test_create_escalation_policy(escalation_policy_internal_api_setup, make_use
data = {
"step": EscalationPolicy.STEP_WAIT,
"wait_delay": "60.0",
"wait_delay": 60,
"escalation_chain": escalation_chain.public_primary_key,
"notify_to_users_queue": [],
"from_time": None,
@ -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": 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
@ -690,7 +712,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.0",
"wait_delay": 300,
"from_time": "06:50:00",
"to_time": "04:10:00",
}

View file

@ -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": 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
@ -252,7 +273,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.0"), ("900.0", "900.0")])
@pytest.mark.parametrize("wait_delay, expected_wait_delay", [(None, 300), (900, 900)])
def test_switch_step_type_from_notify_to_wait(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
@ -400,9 +421,7 @@ def test_switch_notification_channel(
@pytest.mark.django_db
@pytest.mark.parametrize(
"from_wait_delay, to_wait_delay", [(None, "300.0"), (timezone.timedelta(seconds=900), "900.0")]
)
@pytest.mark.parametrize("from_wait_delay, to_wait_delay", [(None, 300), (timezone.timedelta(seconds=900), 900)])
def test_switch_wait_delay(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,

View file

@ -643,6 +643,8 @@ class AlertGroupView(
)
@action(methods=["get"], detail=False)
def silence_options(self, request):
# TODO: DEPRECATED, REMOVE IN A FUTURE RELEASE
"""
Retrieve a list of valid silence options
"""

View file

@ -138,6 +138,7 @@ class EscalationPolicyView(
@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 @@ class EscalationPolicyView(
@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
]

View file

@ -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,12 @@ class TimeZoneAwareDatetimeField(serializers.DateTimeField):
],
**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())

View file

@ -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<EscalationPolicyType>) => 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<EscalationPolicyProps, any> {
private styles: ReturnType<typeof getEscalationPolicyStyles>;
constructor(props: EscalationPolicyProps) {
super(props);
this.styles = getEscalationPolicyStyles(props.theme);
}
componentDidUpdate(prevProps: Readonly<EscalationPolicyProps>): 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 (
<Timeline.Item
key={id}
contentClassName={cx(this.styles.root)}
contentClassName={styles.root}
number={number}
textColor={isDisabled ? itemTextColor : undefined}
backgroundClassName={backgroundClassName}
@ -100,13 +92,10 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)}
{this.renderNote()}
{is_final || isDisabled ? null : (
<WithPermissionControlTooltip
className={cx(this.styles.delete)}
userAction={UserActions.EscalationChainsWrite}
>
<WithPermissionControlTooltip className={styles.delete} userAction={UserActions.EscalationChainsWrite}>
<IconButton
name="trash-alt"
className={cx(this.styles.delete, this.styles.control)}
className={cx(styles.delete, styles.control)}
onClick={this.handleDelete}
size="sm"
tooltip="Delete"
@ -178,9 +167,11 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
const {
data,
isDisabled,
theme,
store: { userStore },
} = this.props;
const { notify_to_users_queue } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="users-multiple" userAction={UserActions.EscalationChainsWrite}>
@ -191,7 +182,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
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) => <UserTooltip id={value} />}
@ -206,14 +197,15 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
}
renderImportance() {
const { data, isDisabled } = this.props;
const { data, isDisabled, theme } = this.props;
const { important } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="importance" userAction={UserActions.EscalationChainsWrite}>
<Select
menuShouldPortal
className={cx(this.styles.select, this.styles.control)}
className={cx(styles.select, styles.control)}
disabled={isDisabled}
value={Number(important)}
// @ts-ignore
@ -251,7 +243,8 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
}
renderTimeRange() {
const { data, isDisabled } = this.props;
const { data, isDisabled, theme } = this.props;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="time-range" userAction={UserActions.EscalationChainsWrite}>
@ -260,46 +253,58 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
to={data.to_time}
disabled={isDisabled}
onChange={this.getOnTimeRangeChangeHandler()}
className={cx(this.styles.select, this.styles.control)}
className={cx(styles.select, styles.control)}
/>
</WithPermissionControlTooltip>
);
}
renderWaitDelays() {
const { data, isDisabled, waitDelays = [] } = this.props;
const { data, isDisabled, theme } = this.props;
const { wait_delay } = data;
const styles = getEscalationPolicyStyles(theme);
const silenceOptions: SelectableValue[] = [...POLICY_DURATION_LIST_MINUTES];
const waitDelayInSeconds = wait_delay ? parseFloat(wait_delay) : 0;
const waitDelayInMinutes = waitDelayInSeconds / 60;
const waitDelayOptionItem = silenceOptions.find((opt) => opt.value === waitDelayInMinutes) || {
value: waitDelayInMinutes,
label: waitDelayInMinutes,
}; // either find it in the list or initialize it to show in the dropdown
return (
<WithPermissionControlTooltip key="wait-delay" userAction={UserActions.EscalationChainsWrite}>
<Select
menuShouldPortal
disabled={isDisabled}
placeholder="Select Wait Delay"
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,
}))}
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)}
/>
</WithPermissionControlTooltip>
);
}
renderNumAlertsInWindow() {
const { data, isDisabled } = this.props;
const { data, isDisabled, theme } = this.props;
const { num_alerts_in_window } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="num_alerts_in_window" userAction={UserActions.EscalationChainsWrite}>
<Input
placeholder="Count"
disabled={isDisabled}
className={cx(this.styles.control)}
className={styles.control}
value={num_alerts_in_window}
onChange={this.getOnInputChangeHandler('num_alerts_in_window')}
ref={(node) => {
@ -314,8 +319,16 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
}
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 (
<WithPermissionControlTooltip key="num_minutes_in_window" userAction={UserActions.EscalationChainsWrite}>
@ -323,14 +336,12 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
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}
/>
</WithPermissionControlTooltip>
);
@ -339,10 +350,12 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
renderNotifySchedule() {
const {
data,
theme,
isDisabled,
store: { grafanaTeamStore, scheduleStore },
} = this.props;
const { notify_schedule } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="notify_schedule" userAction={UserActions.EscalationChainsWrite}>
@ -356,7 +369,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
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<EscalationPolicyProps, any> {
renderNotifyUserGroup() {
const {
data,
theme,
isDisabled,
store: { userGroupStore },
} = this.props;
const { notify_to_group } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="notify_to_group" userAction={UserActions.EscalationChainsWrite}>
@ -393,7 +409,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
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<EscalationPolicyProps, any> {
renderTriggerCustomWebhook() {
const {
data,
theme,
isDisabled,
store: { grafanaTeamStore, outgoingWebhookStore },
} = this.props;
const { custom_webhook } = data;
const styles = getEscalationPolicyStyles(theme);
return (
<WithPermissionControlTooltip key="custom-webhook" userAction={UserActions.EscalationChainsWrite}>
@ -421,7 +440,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
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<EscalationPolicyProps, any> {
);
}
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<EscalationPolicyProps, any> {
}
export const EscalationPolicy = withMobXProviderContext(
SortableElement(withTheme2(_EscalationPolicy)) as React.ComponentClass<EscalationPolicyProps>
);
SortableElement(withTheme2(_EscalationPolicy))
) as unknown as React.ComponentClass<EscalationPolicyBaseProps>;

View file

@ -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<NotificationPolicyProps,
}
private _renderWaitDelays(disabled: boolean) {
const { data, waitDelays = [], userAction } = this.props;
const { data, userAction } = this.props;
const { wait_delay } = data;
const optionsList = [...POLICY_DURATION_LIST_MINUTES];
const waitDelayInSeconds = parseFloat(wait_delay);
const waitDelayInMinutes = waitDelayInSeconds / 60;
const optionValue = POLICY_DURATION_LIST_SECONDS.find((delay) => delay.duration === waitDelayInMinutes) || {
value: waitDelayInMinutes,
label: waitDelayInMinutes,
};
return (
<WithPermissionControlTooltip userAction={userAction}>
<Select
key="wait-delay"
placeholder="Wait Delay"
className={cx(this.styles.select, this.styles.control)}
// @ts-ignore
value={wait_delay}
disabled={disabled}
onChange={this._getOnChangeHandler('wait_delay')}
options={waitDelays.map((waitDelay: SelectOption) => ({
label: waitDelay.display_name,
value: waitDelay.value,
}))}
/>
<div className={this.styles.container}>
<Select
key="wait-delay"
placeholder="Wait Delay"
className={cx(this.styles.delay, this.styles.control)}
value={wait_delay ? optionValue : undefined}
disabled={disabled}
onChange={(option: SelectableValue) => 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)
</div>
</WithPermissionControlTooltip>
);
}
@ -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;
`,
};
};

View file

@ -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' },
];

View file

@ -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}
/>

View file

@ -24,7 +24,6 @@ export class AlertGroupStore {
rootStore: RootStore;
alerts = new Map<string, ApiSchemas['AlertGroup']>();
bulkActions: any = [];
silenceOptions: Array<ApiSchemas['AlertGroupSilenceOptions']>;
searchResult: { [key: string]: Array<ApiSchemas['AlertGroup']['pk']> } = {};
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() {

View file

@ -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, {

View file

@ -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<IncidentPageProps, IncidentPageState
};
componentDidMount() {
const { store } = this.props;
this.update();
store.alertGroupStore.fetchSilenceOptions();
}
componentWillUnmount(): void {
@ -256,7 +253,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
shouldRender={Boolean(silenceModalData?.incident)}
render={() => (
<IncidentSilenceModal
alertGroupID={silenceModalData.incident.pk}
alertGroupID={silenceModalData.incident.inside_organization_number}
alertGroupName={silenceModalData.incident.render_for_web?.title}
isOpen
onDismiss={() => this.setState({ silenceModalData: undefined })}

View file

@ -148,7 +148,6 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
const { alertGroupStore } = store;
alertGroupStore.fetchBulkActions();
alertGroupStore.fetchSilenceOptions();
if (store.hasFeature(AppFeature.Labels)) {
alertGroupStore.fetchTableSettings();

View file

@ -5,6 +5,7 @@ import { intervalToAbbreviatedDurationString } from '@grafana/data';
import { Icon, LoadingPlaceholder, Tooltip, useStyles2 } from '@grafana/ui';
import { getUtilStyles } from 'styles/utils.styles';
import { CUSTOM_SILENCE_VALUE } from 'components/Policy/Policy.consts';
import { Tag, TagColor } from 'components/Tag/Tag';
import { Text } from 'components/Text/Text';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
@ -57,8 +58,6 @@ function IncidentStatusTag({
);
}
export const CUSTOM_SILENCE_VALUE = -100;
export const IncidentDropdown: FC<{
alert: ApiSchemas['AlertGroup'];
onResolve: (e: SyntheticEvent) => Promise<void>;
@ -199,7 +198,6 @@ export const IncidentDropdown: FC<{
<div className={styles.incidentOptionItem}>
<SilenceSelect
customValueNum={CUSTOM_SILENCE_VALUE}
placeholder={
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
}
@ -226,7 +224,7 @@ export const IncidentDropdown: FC<{
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
</WithContextMenu>
<IncidentSilenceModal
alertGroupID={alert.pk}
alertGroupID={alert.inside_organization_number}
alertGroupName={alert.render_for_web?.title}
isOpen={isSilenceModalOpen}
onDismiss={() => setIsSilenceModalOpen(false)}

View file

@ -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<IncidentSilenceModalProps> = ({
isOpen,
alertGroupID,
@ -31,66 +39,118 @@ const IncidentSilenceModal: React.FC<IncidentSilenceModalProps> = ({
onDismiss,
onSave,
}) => {
const [date, setDate] = useState<DateTime>(dateTime('2021-05-05 12:00:00'));
const [duration, setDuration] = useState<string>('');
const [date, setDate] = useState<DateTime>(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<FormFields>({
mode: 'onSubmit',
});
return (
<Modal
onDismiss={onDismiss}
closeOnBackdropClick={false}
isOpen={isOpen}
title={`Silence alert group #${alertGroupID} ${alertGroupName}`}
title={
<Text.Title
level={4}
type="primary"
className={cx(utilStyles.overflowChild, bem(utilStyles.overflowChild, 'line-1'))}
>
Silence alert group #${alertGroupID} ${alertGroupName}
</Text.Title>
}
className={styles.root}
>
<div className={styles.container}>
<Field key={'SilencePicker'} label={'Silence End'} className={styles.containerChild}>
<DateTimePicker label="Date" date={date} onChange={onDateChange} minDate={new Date()} />
</Field>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className={styles.container}>
<Field key={'SilencePicker'} label={'Silence'} className={styles.containerChild}>
<div className={styles.datePicker}>
<DateTimePicker
showSeconds={false}
label="Date"
date={date}
onChange={onDateChange}
minDate={new Date()}
/>
</div>
</Field>
<Field key={'Duration'} label={'Duration'} className={styles.containerChild} invalid={!isDurationValid}>
<Input value={duration} onChange={onDurationChange} placeholder="Enter duration (2h 30m)" />
</Field>
</div>
<Controller
name={'duration'}
control={control}
rules={{
required: 'Duration is required',
validate: (value: string) => {
return value?.trim() && isValidDuration(value) ? true : 'Duration is invalid';
},
}}
render={({ field }) => (
<Field
key={'Duration'}
label={'Duration'}
invalid={!!errors.duration}
error={errors.duration?.message}
className={styles.containerChild}
>
<Input
{...field}
value={field.value}
onChange={(event: React.FormEvent<HTMLInputElement>) => {
const newDuration: string = event.currentTarget.value;
field.onChange(newDuration);
<HorizontalGroup justify="flex-end">
<Button variant={'secondary'} onClick={onDismiss}>
Cancel
</Button>
<Button variant={'primary'} onClick={onSubmit} disabled={!isDurationValid}>
Add
</Button>
</HorizontalGroup>
debouncedUpdateDateTime(newDuration);
}}
placeholder="Enter duration (2h 30m)"
/>
</Field>
)}
/>
</div>
<HorizontalGroup justify="flex-end">
<Button variant={'secondary'} onClick={onDismiss}>
Cancel
</Button>
<Button type="submit" variant={'primary'} disabled={!!errors.duration?.message}>
Silence
</Button>
</HorizontalGroup>
</form>
</Modal>
);
function onDateChange(date: DateTime) {
setDate(date);
const duration = intervalToAbbreviatedDurationString({
start: new Date(),
end: new Date(date.toDate()),
});
setDuration(duration);
function onFormSubmit() {
onSave(durationToMilliseconds(parseDuration(getValues('duration'))) / 1000);
}
function onDurationChange(event: React.SyntheticEvent<HTMLInputElement>) {
const newDuration = event.currentTarget.value;
if (newDuration !== duration) {
setDuration(newDuration);
debouncedUpdateDateTime(newDuration);
function onDateChange(newDate: DateTime) {
const duration = intervalToAbbreviatedDurationString({
start: new Date(),
end: new Date(newDate.toDate()),
});
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;
}
`,
});

View file

@ -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 (
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
@ -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[];
}
});

View file

@ -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];
}
});