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:
parent
f7975f1280
commit
d8e1a1dfae
19 changed files with 385 additions and 236 deletions
|
|
@ -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}:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
46
grafana-plugin/src/components/Policy/Policy.consts.ts
Normal file
46
grafana-plugin/src/components/Policy/Policy.consts.ts
Normal 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' },
|
||||
];
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue