commit
9a1eda004c
47 changed files with 873 additions and 90 deletions
2
.github/workflows/linting-and-tests.yml
vendored
2
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -262,6 +262,8 @@ jobs:
|
|||
pytest -x
|
||||
|
||||
end-to-end-tests:
|
||||
# TODO: reenable this job once https://github.com/grafana/oncall/issues/1692 is fixed
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-latest
|
||||
name: "End to end tests - Grafana: ${{ matrix.grafana-image-tag }}"
|
||||
strategy:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.2.8 (2023-04-06)
|
||||
|
||||
### Changed
|
||||
|
||||
- Allow editing assigned team via public api ([1619](https://github.com/grafana/oncall/pull/1619))
|
||||
- Disable mentions when resolution note is created by @iskhakov ([1696](https://github.com/grafana/oncall/pull/1696))
|
||||
- Display warnings on users page in a clean and consistent way by @iskhakov ([#1681](https://github.com/grafana/oncall/pull/1681))
|
||||
|
||||
## v1.2.7 (2023-04-03)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ integrations, escalation chains, and schedules. OnCall teams are automatically s
|
|||
[Grafana teams](https://grafana.com/docs/grafana/latest/administration/team-management/) created at the organization
|
||||
level of your Grafana instance. To modify global settings like team name or team members, navigate to
|
||||
**Configuration > Teams**. For OnCall-specific team settings,
|
||||
go to **Alerts and Incidents > OnCall > Settings > Teams and Access Settings**.
|
||||
go to **Alerts & IRM > OnCall > Settings > Teams and Access Settings**.
|
||||
|
||||
This section displays a list of teams, allowing you to configure team visibility and access to team resources for all
|
||||
Grafana users, or only admins and team members. You can also set a default team, which is a user-specific setting;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from apps.alerts.models.custom_button import CustomButton
|
|||
from apps.alerts.models.escalation_policy import EscalationPolicy
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.user_management.models import User
|
||||
from apps.webhooks.models import Webhook
|
||||
|
||||
|
||||
class PrimaryKeyRelatedFieldWithNoneValue(serializers.PrimaryKeyRelatedField):
|
||||
|
|
@ -58,6 +59,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer):
|
|||
escalation_counter = serializers.IntegerField(default=0)
|
||||
passed_last_time = serializers.DateTimeField(allow_null=True, default=None)
|
||||
custom_button_trigger = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=CustomButton.objects)
|
||||
custom_webhook = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=Webhook.objects, default=None)
|
||||
notify_schedule = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=OnCallSchedule.objects)
|
||||
num_alerts_in_window = serializers.IntegerField(allow_null=True, default=None)
|
||||
num_minutes_in_window = serializers.IntegerField(allow_null=True, default=None)
|
||||
|
|
@ -77,6 +79,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer):
|
|||
"num_alerts_in_window",
|
||||
"num_minutes_in_window",
|
||||
"custom_button_trigger",
|
||||
"custom_webhook",
|
||||
"notify_schedule",
|
||||
"notify_to_group",
|
||||
"escalation_counter",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from apps.alerts.models.alert_group_log_record import AlertGroupLogRecord
|
|||
from apps.alerts.models.escalation_policy import EscalationPolicy
|
||||
from apps.alerts.tasks import (
|
||||
custom_button_result,
|
||||
custom_webhook_result,
|
||||
notify_all_task,
|
||||
notify_group_task,
|
||||
notify_user_task,
|
||||
|
|
@ -32,6 +33,7 @@ class EscalationPolicySnapshot:
|
|||
"num_alerts_in_window",
|
||||
"num_minutes_in_window",
|
||||
"custom_button_trigger",
|
||||
"custom_webhook",
|
||||
"notify_schedule",
|
||||
"notify_to_group",
|
||||
"escalation_counter",
|
||||
|
|
@ -57,6 +59,7 @@ class EscalationPolicySnapshot:
|
|||
num_alerts_in_window,
|
||||
num_minutes_in_window,
|
||||
custom_button_trigger,
|
||||
custom_webhook,
|
||||
notify_schedule,
|
||||
notify_to_group,
|
||||
escalation_counter,
|
||||
|
|
@ -74,6 +77,7 @@ class EscalationPolicySnapshot:
|
|||
self.num_alerts_in_window = num_alerts_in_window
|
||||
self.num_minutes_in_window = num_minutes_in_window
|
||||
self.custom_button_trigger = custom_button_trigger
|
||||
self.custom_webhook = custom_webhook
|
||||
self.notify_schedule = notify_schedule
|
||||
self.notify_to_group = notify_to_group
|
||||
self.escalation_counter = escalation_counter # used for STEP_REPEAT_ESCALATION_N_TIMES
|
||||
|
|
@ -116,6 +120,7 @@ class EscalationPolicySnapshot:
|
|||
EscalationPolicy.STEP_NOTIFY_SCHEDULE: self._escalation_step_notify_on_call_schedule,
|
||||
EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT: self._escalation_step_notify_on_call_schedule,
|
||||
EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: self._escalation_step_trigger_custom_button,
|
||||
EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: self._escalation_step_trigger_custom_webhook,
|
||||
EscalationPolicy.STEP_NOTIFY_USERS_QUEUE: self._escalation_step_notify_users_queue,
|
||||
EscalationPolicy.STEP_NOTIFY_IF_TIME: self._escalation_step_notify_if_time,
|
||||
EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: self._escalation_step_notify_if_num_alerts_in_time_window,
|
||||
|
|
@ -431,6 +436,29 @@ class EscalationPolicySnapshot:
|
|||
log_record.save()
|
||||
self._execute_tasks(tasks)
|
||||
|
||||
def _escalation_step_trigger_custom_webhook(self, alert_group, **kwargs) -> None:
|
||||
tasks = []
|
||||
webhook = self.custom_webhook
|
||||
if webhook is not None:
|
||||
custom_webhook_task = custom_webhook_result.signature(
|
||||
(webhook.pk, alert_group.pk),
|
||||
{
|
||||
"escalation_policy_pk": self.id,
|
||||
},
|
||||
immutable=True,
|
||||
)
|
||||
tasks.append(custom_webhook_task)
|
||||
else:
|
||||
log_record = AlertGroupLogRecord(
|
||||
type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED,
|
||||
alert_group=alert_group,
|
||||
escalation_policy=self.escalation_policy,
|
||||
escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED,
|
||||
escalation_policy_step=self.step,
|
||||
)
|
||||
log_record.save()
|
||||
self._execute_tasks(tasks)
|
||||
|
||||
def _escalation_step_repeat_escalation_n_times(self, alert_group, **kwargs) -> StepExecutionResultData:
|
||||
if self.escalation_counter < EscalationPolicy.MAX_TIMES_REPEAT:
|
||||
log_record = AlertGroupLogRecord(
|
||||
|
|
|
|||
25
engine/apps/alerts/migrations/0011_auto_20230329_1617.py
Normal file
25
engine/apps/alerts/migrations/0011_auto_20230329_1617.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 3.2.18 on 2023-03-29 16:17
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('webhooks', '0002_auto_20230320_1604'),
|
||||
('alerts', '0010_channelfilter_filtering_term_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='escalationpolicy',
|
||||
name='custom_webhook',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='escalation_policies', to='webhooks.webhook'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='escalationpolicy',
|
||||
name='step',
|
||||
field=models.IntegerField(choices=[(0, 'Wait'), (1, 'Notify User'), (2, 'Notify Whole Channel'), (3, 'Repeat Escalation (5 times max)'), (4, 'Resolve'), (5, 'Notify Group'), (6, 'Notify Schedule'), (7, 'Notify User (Important)'), (8, 'Notify Group (Important)'), (9, 'Notify Schedule (Important)'), (10, 'Trigger Outgoing Webhook'), (11, 'Notify User (next each time)'), (12, 'Continue escalation only if time is from'), (13, 'Notify multiple Users'), (14, 'Notify multiple Users (Important)'), (15, 'Continue escalation if >X alerts per Y minutes'), (16, 'Trigger Webhook')], default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -135,7 +135,8 @@ class AlertGroupLogRecord(models.Model):
|
|||
ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED,
|
||||
ERROR_ESCALATION_NOTIFY_IN_SLACK,
|
||||
ERROR_ESCALATION_NOTIFY_IF_NUM_ALERTS_IN_WINDOW_STEP_IS_NOT_CONFIGURED,
|
||||
) = range(17)
|
||||
ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR,
|
||||
) = range(18)
|
||||
|
||||
type = models.IntegerField(choices=TYPE_CHOICES)
|
||||
|
||||
|
|
@ -436,18 +437,18 @@ class AlertGroupLogRecord(models.Model):
|
|||
f"{f' by {author_name}' if author_name else ''}"
|
||||
)
|
||||
elif self.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED:
|
||||
webhook_name = ""
|
||||
trigger = None
|
||||
if step_specific_info is not None:
|
||||
custom_button_name = step_specific_info.get("custom_button_name")
|
||||
custom_button_name = f"`{custom_button_name}`" or ""
|
||||
webhook_name = step_specific_info.get("webhook_name") or step_specific_info.get("custom_button_name")
|
||||
trigger = step_specific_info.get("trigger")
|
||||
elif self.custom_button is not None:
|
||||
custom_button_name = f"`{self.custom_button.name}`"
|
||||
webhook_name = f"`{self.custom_button.name}`"
|
||||
if trigger is None and self.author:
|
||||
trigger = f"{author_name}"
|
||||
else:
|
||||
custom_button_name = ""
|
||||
result += f"outgoing webhook {custom_button_name} triggered by "
|
||||
if self.author:
|
||||
result += f"{author_name}"
|
||||
else:
|
||||
result += "escalation chain"
|
||||
trigger = trigger or "escalation chain"
|
||||
result += f"outgoing webhook `{webhook_name}` triggered by {trigger}"
|
||||
elif self.type == AlertGroupLogRecord.TYPE_FAILED_ATTACHMENT:
|
||||
if self.alert_group.slack_message is not None:
|
||||
result += (
|
||||
|
|
@ -491,6 +492,14 @@ class AlertGroupLogRecord(models.Model):
|
|||
== AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED
|
||||
):
|
||||
result += 'skipped escalation step "Trigger Outgoing Webhook" because it is not configured'
|
||||
elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR:
|
||||
webhook_name = trigger = ""
|
||||
if step_specific_info is not None:
|
||||
webhook_name = step_specific_info.get("webhook_name", "")
|
||||
trigger = step_specific_info.get("trigger", "")
|
||||
result += f"skipped {trigger} outgoing webhook `{webhook_name}`"
|
||||
if self.reason:
|
||||
result += f": {self.reason}"
|
||||
elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_NOTIFY_IF_TIME_IS_NOT_CONFIGURED:
|
||||
result += 'skipped escalation step "Continue escalation if time" because it is not configured'
|
||||
elif (
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_NOTIFY_MULTIPLE_USERS,
|
||||
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
|
||||
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
|
||||
) = range(16)
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
) = range(17)
|
||||
|
||||
# Must be the same order as previous
|
||||
STEP_CHOICES = (
|
||||
|
|
@ -64,6 +65,7 @@ class EscalationPolicy(OrderedModel):
|
|||
(STEP_NOTIFY_MULTIPLE_USERS, "Notify multiple Users"),
|
||||
(STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, "Notify multiple Users (Important)"),
|
||||
(STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, "Continue escalation if >X alerts per Y minutes"),
|
||||
(STEP_TRIGGER_CUSTOM_WEBHOOK, "Trigger Webhook"),
|
||||
)
|
||||
|
||||
# Ordered step choices available for internal api.
|
||||
|
|
@ -79,6 +81,7 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_NOTIFY_GROUP,
|
||||
# Other
|
||||
STEP_TRIGGER_CUSTOM_BUTTON,
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
STEP_NOTIFY_USERS_QUEUE,
|
||||
STEP_NOTIFY_IF_TIME,
|
||||
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
|
||||
|
|
@ -100,6 +103,7 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_NOTIFY_MULTIPLE_USERS,
|
||||
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
|
||||
STEP_TRIGGER_CUSTOM_BUTTON,
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
STEP_REPEAT_ESCALATION_N_TIMES,
|
||||
]
|
||||
|
||||
|
|
@ -122,6 +126,7 @@ class EscalationPolicy(OrderedModel):
|
|||
),
|
||||
# Other
|
||||
STEP_TRIGGER_CUSTOM_BUTTON: ("Trigger outgoing webhook {{custom_action}}", "Trigger outgoing webhook"),
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK: ("Trigger webhook {{custom_webhook}}", "Trigger webhook"),
|
||||
STEP_NOTIFY_USERS_QUEUE: ("Round robin notification for {{users}}", "Notify users one by one (round-robin)"),
|
||||
STEP_NOTIFY_IF_TIME: (
|
||||
"Continue escalation if current time is in {{timerange}} ",
|
||||
|
|
@ -142,6 +147,7 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_FINAL_NOTIFYALL,
|
||||
STEP_FINAL_RESOLVE,
|
||||
STEP_TRIGGER_CUSTOM_BUTTON,
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
STEP_NOTIFY_USERS_QUEUE,
|
||||
STEP_NOTIFY_IF_TIME,
|
||||
STEP_REPEAT_ESCALATION_N_TIMES,
|
||||
|
|
@ -186,6 +192,7 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_FINAL_RESOLVE,
|
||||
STEP_FINAL_NOTIFYALL,
|
||||
STEP_TRIGGER_CUSTOM_BUTTON,
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
STEP_NOTIFY_IF_TIME,
|
||||
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
|
||||
STEP_REPEAT_ESCALATION_N_TIMES,
|
||||
|
|
@ -202,6 +209,7 @@ class EscalationPolicy(OrderedModel):
|
|||
STEP_NOTIFY_SCHEDULE: "notify_on_call_from_schedule",
|
||||
STEP_NOTIFY_SCHEDULE_IMPORTANT: "notify_on_call_from_schedule",
|
||||
STEP_TRIGGER_CUSTOM_BUTTON: "trigger_action",
|
||||
STEP_TRIGGER_CUSTOM_WEBHOOK: "trigger_webhook",
|
||||
STEP_NOTIFY_USERS_QUEUE: "notify_person_next_each_time",
|
||||
STEP_NOTIFY_MULTIPLE_USERS: "notify_persons",
|
||||
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT: "notify_persons",
|
||||
|
|
@ -256,6 +264,14 @@ class EscalationPolicy(OrderedModel):
|
|||
null=True,
|
||||
)
|
||||
|
||||
custom_webhook = models.ForeignKey(
|
||||
"webhooks.Webhook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="escalation_policies",
|
||||
default=None,
|
||||
null=True,
|
||||
)
|
||||
|
||||
ONE_MINUTE = timezone.timedelta(minutes=1)
|
||||
FIVE_MINUTES = timezone.timedelta(minutes=5)
|
||||
FIFTEEN_MINUTES = timezone.timedelta(minutes=15)
|
||||
|
|
@ -350,6 +366,10 @@ class EscalationPolicy(OrderedModel):
|
|||
if self.custom_button_trigger:
|
||||
result["outgoing_webhook"] = self.custom_button_trigger.insight_logs_verbal
|
||||
result["outgoing_webhook_id"] = self.custom_button_trigger.public_primary_key
|
||||
elif self.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK:
|
||||
if self.custom_button_trigger:
|
||||
result["outgoing_webhook"] = self.custom_webhook.insight_logs_verbal
|
||||
result["outgoing_webhook_id"] = self.custom_webhook.public_primary_key
|
||||
elif self.step in [
|
||||
EscalationPolicy.STEP_NOTIFY_USERS_QUEUE,
|
||||
EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from .check_escalation_finished import check_escalation_finished_task # noqa: F
|
|||
from .create_contact_points_for_datasource import create_contact_points_for_datasource # noqa: F401
|
||||
from .create_contact_points_for_datasource import schedule_create_contact_points_for_datasource # noqa: F401
|
||||
from .custom_button_result import custom_button_result # noqa: F401
|
||||
from .custom_webhook_result import custom_webhook_result # noqa: F401
|
||||
from .delete_alert_group import delete_alert_group # noqa: F401
|
||||
from .distribute_alert import distribute_alert # noqa: F401
|
||||
from .escalate_alert_group import escalate_alert_group # noqa: F401
|
||||
|
|
|
|||
16
engine/apps/alerts/tasks/custom_webhook_result.py
Normal file
16
engine/apps/alerts/tasks/custom_webhook_result.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
)
|
||||
def custom_webhook_result(webhook_pk, alert_group_pk, escalation_policy_pk=None):
|
||||
from apps.webhooks.tasks import execute_webhook
|
||||
|
||||
execute_webhook.apply_async((webhook_pk, alert_group_pk, None, escalation_policy_pk))
|
||||
17
engine/apps/alerts/tests/test_custom_webhook_result.py
Normal file
17
engine/apps/alerts/tests/test_custom_webhook_result.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.alerts.tasks import custom_webhook_result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_webhook_result_executes_webhook():
|
||||
webhook_id = 42
|
||||
alert_group_id = 13
|
||||
escalation_policy_id = 11
|
||||
|
||||
with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute:
|
||||
custom_webhook_result(webhook_id, alert_group_id, escalation_policy_id)
|
||||
|
||||
assert mock_execute.call_args == call((webhook_id, alert_group_id, None, escalation_policy_id))
|
||||
|
|
@ -442,6 +442,37 @@ def test_escalation_step_trigger_custom_button(
|
|||
assert mocked_execute_tasks.called
|
||||
|
||||
|
||||
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_step_trigger_custom_webhook(
|
||||
mocked_execute_tasks,
|
||||
escalation_step_test_setup,
|
||||
make_custom_webhook,
|
||||
make_escalation_policy,
|
||||
):
|
||||
organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup
|
||||
|
||||
custom_webhook = make_custom_webhook(organization=organization)
|
||||
|
||||
trigger_custom_webhook_step = make_escalation_policy(
|
||||
escalation_chain=channel_filter.escalation_chain,
|
||||
escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON,
|
||||
custom_webhook=custom_webhook,
|
||||
)
|
||||
escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(trigger_custom_webhook_step)
|
||||
expected_eta = timezone.now() + timezone.timedelta(seconds=NEXT_ESCALATION_DELAY)
|
||||
result = escalation_policy_snapshot.execute(alert_group, reason)
|
||||
expected_result = EscalationPolicySnapshot.StepExecutionResultData(
|
||||
eta=result.eta,
|
||||
stop_escalation=False,
|
||||
pause_escalation=False,
|
||||
start_from_beginning=False,
|
||||
)
|
||||
assert expected_eta + timezone.timedelta(seconds=15) > result.eta > expected_eta - timezone.timedelta(seconds=15)
|
||||
assert result == expected_result
|
||||
assert mocked_execute_tasks.called
|
||||
|
||||
|
||||
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_step_repeat_escalation_n_times(
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
|
|||
"num_alerts_in_window": None,
|
||||
"num_minutes_in_window": None,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"escalation_counter": 0,
|
||||
"passed_last_time": None,
|
||||
"pause_escalation": False,
|
||||
|
|
@ -63,6 +64,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
|
|||
"num_alerts_in_window": None,
|
||||
"num_minutes_in_window": None,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"escalation_counter": 0,
|
||||
"passed_last_time": None,
|
||||
"pause_escalation": False,
|
||||
|
|
@ -81,6 +83,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
|
|||
"num_alerts_in_window": None,
|
||||
"num_minutes_in_window": None,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"escalation_counter": 0,
|
||||
"passed_last_time": None,
|
||||
"pause_escalation": False,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from apps.alerts.models import CustomButton, EscalationChain, EscalationPolicy
|
|||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.slack.models import SlackUserGroup
|
||||
from apps.user_management.models import User
|
||||
from apps.webhooks.models import Webhook
|
||||
from common.api_helpers.custom_fields import (
|
||||
OrganizationFilteredPrimaryKeyRelatedField,
|
||||
UsersFilteredByOrganizationField,
|
||||
|
|
@ -22,6 +23,7 @@ TO_TIME = "to_time"
|
|||
NUM_ALERTS_IN_WINDOW = "num_alerts_in_window"
|
||||
NUM_MINUTES_IN_WINDOW = "num_minutes_in_window"
|
||||
CUSTOM_BUTTON_TRIGGER = "custom_button_trigger"
|
||||
CUSTOM_WEBHOOK_TRIGGER = "custom_webhook"
|
||||
|
||||
STEP_TYPE_TO_RELATED_FIELD_MAP = {
|
||||
EscalationPolicy.STEP_WAIT: [WAIT_DELAY],
|
||||
|
|
@ -32,6 +34,7 @@ STEP_TYPE_TO_RELATED_FIELD_MAP = {
|
|||
EscalationPolicy.STEP_NOTIFY_IF_TIME: [FROM_TIME, TO_TIME],
|
||||
EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: [NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW],
|
||||
EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: [CUSTOM_BUTTON_TRIGGER],
|
||||
EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: [CUSTOM_WEBHOOK_TRIGGER],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -71,6 +74,12 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
|
|||
allow_null=True,
|
||||
filter_field="organization",
|
||||
)
|
||||
custom_webhook = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
queryset=Webhook.objects,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
filter_field="organization",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = EscalationPolicy
|
||||
|
|
@ -87,13 +96,20 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
|
|||
"num_minutes_in_window",
|
||||
"slack_integration_required",
|
||||
"custom_button_trigger",
|
||||
"custom_webhook",
|
||||
"notify_schedule",
|
||||
"notify_to_group",
|
||||
"important",
|
||||
]
|
||||
read_only_fields = ("order",)
|
||||
|
||||
SELECT_RELATED = ["escalation_chain", "notify_schedule", "notify_to_group", "custom_button_trigger"]
|
||||
SELECT_RELATED = [
|
||||
"escalation_chain",
|
||||
"notify_schedule",
|
||||
"notify_to_group",
|
||||
"custom_button_trigger",
|
||||
"custom_webhook",
|
||||
]
|
||||
PREFETCH_RELATED = ["notify_to_users_queue"]
|
||||
|
||||
def validate(self, data):
|
||||
|
|
@ -108,6 +124,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
|
|||
NUM_ALERTS_IN_WINDOW,
|
||||
NUM_MINUTES_IN_WINDOW,
|
||||
CUSTOM_BUTTON_TRIGGER,
|
||||
CUSTOM_WEBHOOK_TRIGGER,
|
||||
]
|
||||
|
||||
step = data.get("step")
|
||||
|
|
@ -216,6 +233,7 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer):
|
|||
NUM_ALERTS_IN_WINDOW,
|
||||
NUM_MINUTES_IN_WINDOW,
|
||||
CUSTOM_BUTTON_TRIGGER,
|
||||
CUSTOM_WEBHOOK_TRIGGER,
|
||||
]
|
||||
|
||||
for f in STEP_TYPE_TO_RELATED_FIELD_MAP.get(step, []):
|
||||
|
|
|
|||
|
|
@ -59,9 +59,7 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
"last_response_log",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"authorization_header": {"write_only": True},
|
||||
"name": {"required": True, "allow_null": False, "allow_blank": False},
|
||||
"password": {"write_only": True},
|
||||
"url": {"required": True, "allow_null": False, "allow_blank": False},
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +111,6 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
|
||||
def get_trigger_type_name(self, obj):
|
||||
trigger_type_name = ""
|
||||
if obj.trigger_type:
|
||||
if obj.trigger_type is not None:
|
||||
trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1]
|
||||
return trigger_type_name
|
||||
|
|
|
|||
|
|
@ -55,6 +55,33 @@ def test_create_escalation_policy(escalation_policy_internal_api_setup, make_use
|
|||
assert response.data["order"] == max_order + 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_escalation_policy_webhook(
|
||||
escalation_policy_internal_api_setup, make_custom_webhook, make_user_auth_headers
|
||||
):
|
||||
token, escalation_chain, _, user, _ = escalation_policy_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:escalation_policy-list")
|
||||
|
||||
webhook = make_custom_webhook(organization=user.organization)
|
||||
data = {
|
||||
"step": EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
"escalation_chain": escalation_chain.public_primary_key,
|
||||
"custom_webhook": webhook.public_primary_key,
|
||||
}
|
||||
|
||||
max_order = EscalationPolicy.objects.filter(escalation_chain=escalation_chain).aggregate(maxorder=Max("order"))[
|
||||
"maxorder"
|
||||
]
|
||||
|
||||
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["order"] == max_order + 1
|
||||
assert response.data["custom_webhook"] == webhook.public_primary_key
|
||||
escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"])
|
||||
assert escalation_policy.custom_webhook == webhook
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup, make_user_auth_headers):
|
||||
token, _, escalation_policy, first_user, second_user = escalation_policy_internal_api_setup
|
||||
|
|
@ -624,6 +651,7 @@ def test_escalation_policy_can_not_create_with_non_step_type_related_data(
|
|||
(EscalationPolicy.STEP_NOTIFY_IF_TIME, ["from_time", "to_time"]),
|
||||
(EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, ["notify_to_users_queue"]),
|
||||
(EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, ["custom_button_trigger"]),
|
||||
(EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, ["custom_webhook"]),
|
||||
],
|
||||
)
|
||||
def test_escalation_policy_update_drop_non_step_type_related_data(
|
||||
|
|
@ -662,6 +690,7 @@ def test_escalation_policy_update_drop_non_step_type_related_data(
|
|||
"from_time",
|
||||
"to_time",
|
||||
"custom_button_trigger",
|
||||
"custom_webhook",
|
||||
]
|
||||
for f in related_fields:
|
||||
fields_to_check.remove(f)
|
||||
|
|
@ -713,6 +742,7 @@ def test_escalation_policy_switch_importance(
|
|||
"num_minutes_in_window": None,
|
||||
"slack_integration_required": escalation_policy.slack_integration_required,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"notify_schedule": None,
|
||||
"notify_to_group": None,
|
||||
"important": True,
|
||||
|
|
@ -770,6 +800,7 @@ def test_escalation_policy_filter_by_user(
|
|||
"num_minutes_in_window": None,
|
||||
"slack_integration_required": False,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"notify_schedule": None,
|
||||
"notify_to_group": None,
|
||||
"important": False,
|
||||
|
|
@ -787,6 +818,7 @@ def test_escalation_policy_filter_by_user(
|
|||
"num_minutes_in_window": None,
|
||||
"slack_integration_required": False,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"notify_schedule": None,
|
||||
"notify_to_group": None,
|
||||
"important": False,
|
||||
|
|
@ -849,6 +881,7 @@ def test_escalation_policy_filter_by_slack_channel(
|
|||
"num_minutes_in_window": None,
|
||||
"slack_integration_required": False,
|
||||
"custom_button_trigger": None,
|
||||
"custom_webhook": None,
|
||||
"notify_schedule": None,
|
||||
"notify_to_group": None,
|
||||
"important": False,
|
||||
|
|
@ -864,3 +897,25 @@ def test_escalation_policy_filter_by_slack_channel(
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
assert response.json() == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_escalation_policy_escalation_options_webhooks(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
enabled,
|
||||
):
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:escalation_policy-escalation-options")
|
||||
|
||||
with patch("apps.api.views.escalation_policy.is_webhooks_enabled_for_organization", return_value=enabled):
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
returned_options = [option["value"] for option in response.json()]
|
||||
if enabled:
|
||||
assert EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK in returned_options
|
||||
else:
|
||||
assert EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK not in returned_options
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING, LegacyAccessControlRole
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.user_management.models.user import default_working_hours
|
||||
|
||||
|
||||
|
|
@ -1650,3 +1651,187 @@ def test_phone_number_verification_recaptcha(
|
|||
mock_verification_start.assert_called_once_with()
|
||||
else:
|
||||
mock_verification_start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upcoming_shifts_invalid_days(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key}) + "?days=invalid"
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upcoming_shifts_oncall(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
other_user = make_user_for_organization(organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
|
||||
)
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
schedule.refresh_ical_file()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_data = response.data
|
||||
assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name
|
||||
assert returned_data[schedule.public_primary_key]["is_oncall"]
|
||||
assert returned_data[schedule.public_primary_key]["current_shift"]["start"] == on_call_shift.start
|
||||
next_shift_start = on_call_shift.start + timezone.timedelta(days=1)
|
||||
assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == next_shift_start
|
||||
|
||||
# empty response for other user
|
||||
url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": other_user.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upcoming_shifts_override(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
)
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
override_data = {
|
||||
"start": today + timezone.timedelta(hours=22),
|
||||
"rotation_start": today + timezone.timedelta(hours=22),
|
||||
"duration": timezone.timedelta(hours=1),
|
||||
"schedule": schedule,
|
||||
}
|
||||
override = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
|
||||
)
|
||||
override.add_rolling_users([[admin]])
|
||||
schedule.refresh_ical_file()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_data = response.data
|
||||
assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name
|
||||
assert returned_data[schedule.public_primary_key]["is_oncall"] is False
|
||||
assert returned_data[schedule.public_primary_key]["current_shift"] is None
|
||||
assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == override.start
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upcoming_shifts_multiple_schedules(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedules = []
|
||||
for i in range(3):
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
|
||||
)
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
schedule.refresh_ical_file()
|
||||
schedules.append(schedule)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned_data = response.data
|
||||
for i, schedule in enumerate(schedules):
|
||||
assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name
|
||||
expected_start = today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i)
|
||||
if i == 0:
|
||||
assert returned_data[schedule.public_primary_key]["is_oncall"]
|
||||
assert returned_data[schedule.public_primary_key]["current_shift"]["start"] == expected_start
|
||||
assert returned_data[schedule.public_primary_key]["next_shift"][
|
||||
"start"
|
||||
] == expected_start + timezone.timedelta(days=1)
|
||||
else:
|
||||
assert returned_data[schedule.public_primary_key]["is_oncall"] is False
|
||||
assert returned_data[schedule.public_primary_key]["current_shift"] is None
|
||||
assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == expected_start
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"url": "https://github.com/",
|
||||
"data": '{"name": "{{ alert_payload }}"}',
|
||||
"username": "Chris Vanstras",
|
||||
"password": "qwerty",
|
||||
"authorization_header": "auth_token",
|
||||
"forward_all": False,
|
||||
"headers": None,
|
||||
"http_method": "POST",
|
||||
|
|
@ -81,6 +83,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"url": "https://github.com/",
|
||||
"data": '{"name": "{{ alert_payload }}"}',
|
||||
"username": "Chris Vanstras",
|
||||
"password": "qwerty",
|
||||
"authorization_header": "auth_token",
|
||||
"forward_all": False,
|
||||
"headers": None,
|
||||
"http_method": "POST",
|
||||
|
|
@ -123,6 +127,8 @@ def test_create_webhook(mocked_check_webhooks_2_enabled, webhook_internal_api_se
|
|||
"id": webhook.public_primary_key,
|
||||
"data": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"authorization_header": None,
|
||||
"forward_all": True,
|
||||
"headers": None,
|
||||
"http_method": "POST",
|
||||
|
|
@ -177,6 +183,8 @@ def test_create_valid_templated_field(
|
|||
expected_response = data | {
|
||||
"id": webhook.public_primary_key,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"authorization_header": None,
|
||||
"forward_all": True,
|
||||
"headers": None,
|
||||
"data": None,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from apps.api.serializers.escalation_policy import (
|
|||
EscalationPolicyUpdateSerializer,
|
||||
)
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.webhooks.utils import is_webhooks_enabled_for_organization
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import (
|
||||
CreateSerializerMixin,
|
||||
|
|
@ -140,6 +141,10 @@ class EscalationPolicyView(
|
|||
def escalation_options(self, request):
|
||||
choices = []
|
||||
for step in EscalationPolicy.INTERNAL_API_STEPS:
|
||||
if step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK and not is_webhooks_enabled_for_organization(
|
||||
self.request.auth.organization.pk
|
||||
):
|
||||
continue
|
||||
verbal = EscalationPolicy.INTERNAL_API_STEPS_TO_VERBAL_MAP[step]
|
||||
can_change_importance = (
|
||||
step in EscalationPolicy.IMPORTANT_STEPS_SET or step in EscalationPolicy.DEFAULT_STEPS_SET
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from apps.auth_token.models import UserScheduleExportAuthToken
|
|||
from apps.base.messaging import get_messaging_backend_from_id
|
||||
from apps.base.utils import live_settings
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.telegram.client import TelegramClient
|
||||
from apps.telegram.models import TelegramVerificationCode
|
||||
from apps.twilioapp.phone_manager import PhoneManager
|
||||
|
|
@ -139,6 +140,7 @@ class UserView(
|
|||
"unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
}
|
||||
|
||||
rbac_object_permissions = {
|
||||
|
|
@ -159,6 +161,7 @@ class UserView(
|
|||
"unlink_backend",
|
||||
"make_test_call",
|
||||
"export_token",
|
||||
"upcoming_shifts",
|
||||
],
|
||||
IsOwnerOrHasUserSettingsReadPermission: [
|
||||
"check_availability",
|
||||
|
|
@ -460,6 +463,31 @@ class UserView(
|
|||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def upcoming_shifts(self, request, pk):
|
||||
user = self.get_object()
|
||||
try:
|
||||
days = int(request.query_params.get("days", 7)) # fallback to a week
|
||||
except ValueError:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# filter user-related schedules
|
||||
schedules = OnCallSchedule.objects.related_to_user(user)
|
||||
|
||||
# check upcoming shifts
|
||||
upcoming = {}
|
||||
for schedule in schedules:
|
||||
current_shift, upcoming_shift = schedule.upcoming_shift_for_user(user, days=days)
|
||||
if current_shift or upcoming_shift:
|
||||
upcoming[schedule.public_primary_key] = {
|
||||
"schedule": schedule.name,
|
||||
"is_oncall": current_shift is not None,
|
||||
"current_shift": current_shift,
|
||||
"next_shift": upcoming_shift,
|
||||
}
|
||||
|
||||
return Response(upcoming, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def export_token(self, request, pk):
|
||||
user = self.get_object()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
from django.apps import apps
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework.decorators import action
|
||||
|
|
@ -12,6 +11,7 @@ from apps.api.permissions import RBACPermission
|
|||
from apps.api.serializers.webhook import WebhookSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.utils import is_webhooks_enabled_for_organization
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
|
||||
|
|
@ -85,16 +85,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
instance.delete()
|
||||
|
||||
def check_webhooks_2_enabled(self):
|
||||
DynamicSetting = apps.get_model("base", "DynamicSetting")
|
||||
enabled_webhooks_2_orgs = DynamicSetting.objects.get_or_create(
|
||||
name="enabled_webhooks_2_orgs",
|
||||
defaults={
|
||||
"json_value": {
|
||||
"org_ids": [],
|
||||
}
|
||||
},
|
||||
)[0]
|
||||
if self.request.auth.organization.pk not in enabled_webhooks_2_orgs.json_value["org_ids"]:
|
||||
if not is_webhooks_enabled_for_organization(self.request.auth.organization.pk):
|
||||
raise PermissionDenied("Webhooks 2 not enabled for organization. Permission denied.")
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ class ActionCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class ActionUpdateSerializer(ActionCreateSerializer):
|
||||
team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True)
|
||||
url = serializers.CharField(required=False, allow_null=False, allow_blank=False, source="webhook")
|
||||
|
||||
class Meta(ActionCreateSerializer.Meta):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,3 @@ class EscalationChainSerializer(serializers.ModelSerializer):
|
|||
"organization",
|
||||
"team_id",
|
||||
)
|
||||
|
||||
|
||||
class EscalationChainUpdateSerializer(EscalationChainSerializer):
|
||||
team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True)
|
||||
|
|
|
|||
|
|
@ -359,7 +359,6 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
|
|||
|
||||
class IntegrationUpdateSerializer(IntegrationSerializer):
|
||||
type = IntegrationTypeField(source="integration", read_only=True)
|
||||
team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data)
|
||||
|
|
|
|||
|
|
@ -359,7 +359,6 @@ class CustomOnCallShiftUpdateSerializer(CustomOnCallShiftSerializer):
|
|||
name = serializers.CharField(required=False)
|
||||
start = serializers.DateTimeField(required=False)
|
||||
rotation_start = serializers.DateTimeField(required=False)
|
||||
team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
event_type = validated_data.get("type", instance.type)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from apps.schedules.tasks import (
|
|||
schedule_notify_about_empty_shifts_in_schedule,
|
||||
schedule_notify_about_gaps_in_schedule,
|
||||
)
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField
|
||||
from common.api_helpers.custom_fields import UsersFilteredByOrganizationField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.timezones import TimeZoneField
|
||||
|
||||
|
|
@ -60,7 +60,6 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer):
|
|||
|
||||
class ScheduleCalendarUpdateSerializer(ScheduleCalendarSerializer):
|
||||
time_zone = TimeZoneField(required=False)
|
||||
team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team")
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleCalendar
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class ScheduleICalSerializer(ScheduleBaseSerializer):
|
|||
|
||||
|
||||
class ScheduleICalUpdateSerializer(ScheduleICalSerializer):
|
||||
team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team")
|
||||
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleICal
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from common.timezones import TimeZoneField
|
|||
|
||||
|
||||
class ScheduleWebSerializer(ScheduleBaseSerializer):
|
||||
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
|
||||
time_zone = TimeZoneField(required=True)
|
||||
shifts = UsersFilteredByOrganizationField(
|
||||
queryset=CustomOnCallShift.objects,
|
||||
|
|
@ -49,7 +50,6 @@ class ScheduleWebSerializer(ScheduleBaseSerializer):
|
|||
|
||||
class ScheduleWebUpdateSerializer(ScheduleWebSerializer):
|
||||
time_zone = TimeZoneField(required=False)
|
||||
team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team")
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleWeb
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@ from rest_framework.viewsets import ModelViewSet
|
|||
from apps.alerts.models import EscalationChain
|
||||
from apps.auth_token.auth import ApiTokenAuthentication
|
||||
from apps.public_api.serializers import EscalationChainSerializer
|
||||
from apps.public_api.serializers.escalation_chains import EscalationChainUpdateSerializer
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from common.api_helpers.filters import ByTeamFilter
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin
|
||||
from common.api_helpers.paginators import FiftyPageSizePaginator
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
|
||||
|
||||
class EscalationChainView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
|
||||
class EscalationChainView(RateLimitHeadersMixin, ModelViewSet):
|
||||
authentication_classes = (ApiTokenAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
|
@ -22,7 +21,6 @@ class EscalationChainView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelVie
|
|||
|
||||
model = EscalationChain
|
||||
serializer_class = EscalationChainSerializer
|
||||
update_serializer_class = EscalationChainUpdateSerializer
|
||||
|
||||
pagination_class = FiftyPageSizePaginator
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from django.apps import apps
|
|||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.utils import DatabaseError
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
|
@ -72,6 +73,15 @@ class OnCallScheduleQuerySet(PolymorphicQuerySet):
|
|||
def get_oncall_users(self, events_datetime=None):
|
||||
return get_oncall_users_for_multiple_schedules(self, events_datetime)
|
||||
|
||||
def related_to_user(self, user):
|
||||
return self.filter(
|
||||
Q(cached_ical_file_primary__contains=user.username)
|
||||
| Q(cached_ical_file_primary__contains=user.email)
|
||||
| Q(cached_ical_file_overrides__contains=user.username)
|
||||
| Q(cached_ical_file_overrides__contains=user.username),
|
||||
organization=user.organization,
|
||||
)
|
||||
|
||||
|
||||
class OnCallSchedule(PolymorphicModel):
|
||||
objects = PolymorphicManager.from_queryset(OnCallScheduleQuerySet)()
|
||||
|
|
@ -284,6 +294,28 @@ class OnCallSchedule(PolymorphicModel):
|
|||
events = self._resolve_schedule(events)
|
||||
return events
|
||||
|
||||
def upcoming_shift_for_user(self, user, days=7):
|
||||
user_tz = user.timezone or "UTC"
|
||||
now = timezone.now()
|
||||
starting_date = now.date()
|
||||
current_shift = upcoming_shift = None
|
||||
|
||||
events = self.final_events(user_tz, starting_date, days=days)
|
||||
for e in events:
|
||||
if e["end"] < now:
|
||||
# shift is finished, ignore
|
||||
continue
|
||||
users = {u["pk"] for u in e["users"]}
|
||||
if user.public_primary_key in users:
|
||||
if e["start"] < now and e["end"] > now:
|
||||
# shift is in progress
|
||||
current_shift = e
|
||||
continue
|
||||
upcoming_shift = e
|
||||
break
|
||||
|
||||
return current_shift, upcoming_shift
|
||||
|
||||
def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport:
|
||||
"""
|
||||
Return schedule quality report to be used by the web UI.
|
||||
|
|
|
|||
|
|
@ -1153,3 +1153,96 @@ def test_polymorphic_delete_related(
|
|||
# Check that deleting the organization works as expected
|
||||
organization.hard_delete()
|
||||
assert not OnCallSchedule.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_related_schedules(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
schedule1 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
|
||||
)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule1,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
schedule1.refresh_ical_file()
|
||||
|
||||
schedule2 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
override_data = {
|
||||
"start": today + timezone.timedelta(hours=22),
|
||||
"rotation_start": today + timezone.timedelta(hours=22),
|
||||
"duration": timezone.timedelta(hours=1),
|
||||
"schedule": schedule2,
|
||||
}
|
||||
override = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
|
||||
)
|
||||
override.add_rolling_users([[admin]])
|
||||
schedule2.refresh_ical_file()
|
||||
|
||||
# schedule2
|
||||
make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
|
||||
schedules = OnCallSchedule.objects.related_to_user(admin)
|
||||
assert list(schedules) == [schedule1, schedule2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upcoming_shift_for_user(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
other_user = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
|
||||
)
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
schedule.refresh_ical_file()
|
||||
|
||||
current_shift, upcoming_shift = schedule.upcoming_shift_for_user(admin)
|
||||
assert current_shift is not None and current_shift["start"] == on_call_shift.start
|
||||
next_shift_start = on_call_shift.start + timezone.timedelta(days=1)
|
||||
assert upcoming_shift is not None and upcoming_shift["start"] == next_shift_start
|
||||
|
||||
current_shift, upcoming_shift = schedule.upcoming_shift_for_user(other_user)
|
||||
assert current_shift is None
|
||||
assert upcoming_shift is None
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
|
|||
|
||||
def get_resolution_note_blocks(self, resolution_note):
|
||||
blocks = []
|
||||
author_verbal = resolution_note.author_verbal(mention=True)
|
||||
author_verbal = resolution_note.author_verbal(mention=False)
|
||||
resolution_note_text_block = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": resolution_note.text},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from celery.utils.log import get_task_logger
|
|||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, EscalationPolicy
|
||||
from apps.user_management.models import User
|
||||
from apps.webhooks.models import Webhook, WebhookResponse
|
||||
from apps.webhooks.utils import (
|
||||
|
|
@ -29,6 +29,7 @@ TRIGGER_TYPE_TO_LABEL = {
|
|||
Webhook.TRIGGER_SILENCE: "silence",
|
||||
Webhook.TRIGGER_UNSILENCE: "unsilence",
|
||||
Webhook.TRIGGER_UNRESOLVE: "unresolve",
|
||||
Webhook.TRIGGER_ESCALATION_STEP: "escalation",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -40,18 +41,14 @@ def send_webhook_event(trigger_type, alert_group_id, team_id=None, organization_
|
|||
webhooks_qs = Webhooks.objects.filter(trigger_type=trigger_type, organization_id=organization_id, team_id=team_id)
|
||||
|
||||
for webhook in webhooks_qs:
|
||||
execute_webhook.apply_async((webhook.pk, alert_group_id, user_id))
|
||||
execute_webhook.apply_async((webhook.pk, alert_group_id, user_id, None))
|
||||
|
||||
|
||||
def _isoformat_date(date_value):
|
||||
return date_value.isoformat() if date_value else None
|
||||
|
||||
|
||||
def _build_payload(trigger_type, alert_group, user_id):
|
||||
user = None
|
||||
if user_id is not None:
|
||||
user = User.objects.filter(pk=user_id).first()
|
||||
|
||||
def _build_payload(trigger_type, alert_group, user):
|
||||
event = {
|
||||
"type": TRIGGER_TYPE_TO_LABEL[trigger_type],
|
||||
}
|
||||
|
|
@ -83,7 +80,7 @@ def _build_payload(trigger_type, alert_group, user_id):
|
|||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
)
|
||||
def execute_webhook(webhook_pk, alert_group_id, user_id):
|
||||
def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id):
|
||||
Webhooks = apps.get_model("webhooks", "Webhook")
|
||||
try:
|
||||
webhook = Webhooks.objects.get(pk=webhook_pk)
|
||||
|
|
@ -96,7 +93,11 @@ def execute_webhook(webhook_pk, alert_group_id, user_id):
|
|||
except AlertGroup.DoesNotExist:
|
||||
return
|
||||
|
||||
data = _build_payload(webhook.trigger_type, alert_group, user_id)
|
||||
user = None
|
||||
if user_id is not None:
|
||||
user = User.objects.filter(pk=user_id).first()
|
||||
|
||||
data = _build_payload(webhook.trigger_type, alert_group, user)
|
||||
status = {
|
||||
"url": None,
|
||||
"request_trigger": None,
|
||||
|
|
@ -107,7 +108,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id):
|
|||
"webhook": webhook,
|
||||
}
|
||||
|
||||
exception = None
|
||||
exception = error = None
|
||||
try:
|
||||
triggered, status["request_trigger"] = webhook.check_trigger(data)
|
||||
if triggered:
|
||||
|
|
@ -128,15 +129,15 @@ def execute_webhook(webhook_pk, alert_group_id, user_id):
|
|||
# do not add a log entry if the webhook is not triggered
|
||||
return
|
||||
except InvalidWebhookUrl as e:
|
||||
status["url"] = e.message
|
||||
status["url"] = error = e.message
|
||||
except InvalidWebhookTrigger as e:
|
||||
status["request_trigger"] = e.message
|
||||
status["request_trigger"] = error = e.message
|
||||
except InvalidWebhookHeaders as e:
|
||||
status["request_headers"] = e.message
|
||||
status["request_headers"] = error = e.message
|
||||
except InvalidWebhookData as e:
|
||||
status["request_data"] = e.message
|
||||
status["request_data"] = error = e.message
|
||||
except Exception as e:
|
||||
status["content"] = str(e)
|
||||
status["content"] = error = str(e)
|
||||
exception = e
|
||||
|
||||
# create response entry
|
||||
|
|
@ -146,5 +147,35 @@ def execute_webhook(webhook_pk, alert_group_id, user_id):
|
|||
**status,
|
||||
)
|
||||
|
||||
escalation_policy = step = None
|
||||
if escalation_policy_id:
|
||||
escalation_policy = EscalationPolicy.objects.filter(pk=escalation_policy_id).first()
|
||||
step = EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK
|
||||
|
||||
# create log record
|
||||
error_code = None
|
||||
# reuse existing webhooks record type (TODO: rename after migration)
|
||||
log_type = AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED
|
||||
reason = str(status["status_code"])
|
||||
if error is not None:
|
||||
log_type = AlertGroupLogRecord.TYPE_ESCALATION_FAILED
|
||||
error_code = AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR
|
||||
reason = error
|
||||
|
||||
AlertGroupLogRecord.objects.create(
|
||||
type=log_type,
|
||||
alert_group=alert_group,
|
||||
author=user,
|
||||
reason=reason,
|
||||
step_specific_info={
|
||||
"webhook_name": webhook.name,
|
||||
"webhook_id": webhook.public_primary_key,
|
||||
"trigger": TRIGGER_TYPE_TO_LABEL[webhook.trigger_type],
|
||||
},
|
||||
escalation_policy=escalation_policy,
|
||||
escalation_policy_step=step,
|
||||
escalation_error_code=error_code,
|
||||
)
|
||||
|
||||
if exception:
|
||||
raise exception
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from unittest.mock import call, patch
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.tasks import execute_webhook, send_webhook_event
|
||||
|
|
@ -42,7 +43,7 @@ def test_send_webhook_event_filters(
|
|||
for trigger_type, _ in Webhook.TRIGGER_TYPES:
|
||||
with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute:
|
||||
send_webhook_event(trigger_type, alert_group.pk, organization_id=organization.pk)
|
||||
assert mock_execute.call_args == call((webhooks[trigger_type].pk, alert_group.pk, None))
|
||||
assert mock_execute.call_args == call((webhooks[trigger_type].pk, alert_group.pk, None, None))
|
||||
|
||||
# other team
|
||||
alert_receive_channel = make_alert_receive_channel(organization, team=other_team)
|
||||
|
|
@ -51,14 +52,14 @@ def test_send_webhook_event_filters(
|
|||
send_webhook_event(
|
||||
Webhook.TRIGGER_ACKNOWLEDGE, alert_group.pk, organization_id=organization.pk, team_id=other_team.pk
|
||||
)
|
||||
assert mock_execute.call_args == call((other_team_webhook.pk, alert_group.pk, None))
|
||||
assert mock_execute.call_args == call((other_team_webhook.pk, alert_group.pk, None, None))
|
||||
|
||||
# other org
|
||||
alert_receive_channel = make_alert_receive_channel(other_organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute:
|
||||
send_webhook_event(Webhook.TRIGGER_NEW, alert_group.pk, organization_id=other_organization.pk)
|
||||
assert mock_execute.call_args == call((other_org_webhook.pk, alert_group.pk, None))
|
||||
assert mock_execute.call_args == call((other_org_webhook.pk, alert_group.pk, None, None))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -89,7 +90,7 @@ def test_execute_webhook_ok(
|
|||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk)
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
assert mock_requests.post.called
|
||||
expected_call = call(
|
||||
|
|
@ -106,6 +107,75 @@ def test_execute_webhook_ok(
|
|||
assert log.request_data == json.dumps({"value": alert_group.public_primary_key})
|
||||
assert log.request_headers == json.dumps({"some-header": alert_group.public_primary_key})
|
||||
assert log.url == "https://something/{}/".format(alert_group.public_primary_key)
|
||||
# check log record
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED
|
||||
expected_info = {
|
||||
"trigger": "acknowledge",
|
||||
"webhook_id": webhook.public_primary_key,
|
||||
"webhook_name": webhook.name,
|
||||
}
|
||||
assert log_record.step_specific_info == expected_info
|
||||
assert log_record.escalation_policy is None
|
||||
assert log_record.escalation_policy_step is None
|
||||
assert log_record.rendered_log_line_action() == f"outgoing webhook `{webhook.name}` triggered by acknowledge"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_via_escalation_ok(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_custom_webhook,
|
||||
make_escalation_chain,
|
||||
make_escalation_policy,
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(
|
||||
alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True, acknowledged_by=user.pk
|
||||
)
|
||||
webhook = make_custom_webhook(
|
||||
organization=organization,
|
||||
url="https://something/{{ alert_group_id }}/",
|
||||
http_method="POST",
|
||||
trigger_type=Webhook.TRIGGER_ESCALATION_STEP,
|
||||
trigger_template="{{{{ alert_group.integration_id == '{}' }}}}".format(
|
||||
alert_receive_channel.public_primary_key
|
||||
),
|
||||
headers='{"some-header": "{{ alert_group_id }}"}',
|
||||
data='{"value": "{{ alert_group_id }}"}',
|
||||
forward_all=False,
|
||||
)
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
escalation_policy = make_escalation_policy(
|
||||
escalation_chain=escalation_chain,
|
||||
escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
custom_webhook=webhook,
|
||||
)
|
||||
|
||||
mock_response = MockResponse()
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, escalation_policy.pk)
|
||||
|
||||
assert mock_requests.post.called
|
||||
# check log record
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED
|
||||
expected_info = {
|
||||
"trigger": "escalation",
|
||||
"webhook_id": webhook.public_primary_key,
|
||||
"webhook_name": webhook.name,
|
||||
}
|
||||
assert log_record.step_specific_info == expected_info
|
||||
assert log_record.escalation_policy == escalation_policy
|
||||
assert log_record.escalation_policy_step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK
|
||||
assert log_record.rendered_log_line_action() == f"outgoing webhook `{webhook.name}` triggered by escalation"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -131,7 +201,7 @@ def test_execute_webhook_ok_forward_all(
|
|||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk)
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
assert mock_requests.post.called
|
||||
expected_data = {
|
||||
|
|
@ -197,7 +267,7 @@ def test_execute_webhook_using_responses_data(
|
|||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk)
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
assert mock_requests.post.called
|
||||
expected_data = {"value": "updated"}
|
||||
|
|
@ -232,7 +302,7 @@ def test_execute_webhook_trigger_false(
|
|||
)
|
||||
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None)
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
# check no logs
|
||||
|
|
@ -293,7 +363,7 @@ def test_execute_webhook_errors(
|
|||
# make it a valid URL when resolving name
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None)
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
log = webhook.responses.all()[0]
|
||||
|
|
@ -301,3 +371,16 @@ def test_execute_webhook_errors(
|
|||
assert log.content is None
|
||||
error = getattr(log, log_field_name)
|
||||
assert error == expected_error
|
||||
# check log record
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR
|
||||
expected_info = {
|
||||
"trigger": "resolve",
|
||||
"webhook_id": webhook.public_primary_key,
|
||||
"webhook_name": webhook.name,
|
||||
}
|
||||
assert log_record.step_specific_info == expected_info
|
||||
assert log_record.reason == expected_error
|
||||
assert (
|
||||
log_record.rendered_log_line_action() == f"skipped resolve outgoing webhook `{webhook.name}`: {expected_error}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import re
|
|||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from apps.base.utils import live_settings
|
||||
|
|
@ -132,3 +133,16 @@ def serialize_event(event, alert_group, user, responses=None):
|
|||
data["responses"] = responses
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def is_webhooks_enabled_for_organization(organization_id):
|
||||
DynamicSetting = apps.get_model("base", "DynamicSetting")
|
||||
enabled_webhooks_orgs = DynamicSetting.objects.get_or_create(
|
||||
name="enabled_webhooks_2_orgs",
|
||||
defaults={
|
||||
"json_value": {
|
||||
"org_ids": [],
|
||||
}
|
||||
},
|
||||
)[0]
|
||||
return organization_id in enabled_webhooks_orgs.json_value["org_ids"]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from 'models/escalation_policy/escalation_policy.types';
|
||||
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
|
||||
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
|
||||
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
|
||||
import { ScheduleStore } from 'models/schedule/schedule';
|
||||
import { WaitDelay } from 'models/wait_delay';
|
||||
import { SelectOption } from 'state/types';
|
||||
|
|
@ -47,6 +48,7 @@ export interface EscalationPolicyProps {
|
|||
isSlackInstalled: boolean;
|
||||
teamStore: GrafanaTeamStore;
|
||||
outgoingWebhookStore: OutgoingWebhookStore;
|
||||
outgoingWebhook2Store: OutgoingWebhook2Store;
|
||||
scheduleStore: ScheduleStore;
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +101,8 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
|
|||
return this._renderNotifySchedule();
|
||||
case 'custom_action':
|
||||
return this._renderTriggerCustomAction();
|
||||
case 'custom_webhook':
|
||||
return this._renderTriggerCustomWebhook();
|
||||
case 'num_alerts_in_window':
|
||||
return this.renderNumAlertsInWindow();
|
||||
case 'num_minutes_in_window':
|
||||
|
|
@ -178,8 +182,30 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
|
|||
// @ts-ignore
|
||||
onChange={this._getOnSelectChangeHandler('important')}
|
||||
options={[
|
||||
{ value: 0, label: 'Default' },
|
||||
{ value: 1, label: 'Important' },
|
||||
{
|
||||
value: 0,
|
||||
label: 'Default',
|
||||
// @ts-ignore
|
||||
description: (
|
||||
<>
|
||||
Manage "Default notifications"
|
||||
<br />
|
||||
in personal settings
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Important',
|
||||
// @ts-ignore
|
||||
description: (
|
||||
<>
|
||||
Manage "Important notifications"
|
||||
<br />
|
||||
in personal settings
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
width={'auto'}
|
||||
/>
|
||||
|
|
@ -365,6 +391,40 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
|
|||
);
|
||||
}
|
||||
|
||||
private _renderTriggerCustomWebhook() {
|
||||
const { data, teamStore, outgoingWebhook2Store } = this.props;
|
||||
const { custom_webhook } = data;
|
||||
|
||||
return (
|
||||
<WithPermissionControlTooltip
|
||||
key="custom-webhook"
|
||||
disableByPaywall
|
||||
userAction={UserActions.EscalationChainsWrite}
|
||||
>
|
||||
<GSelect
|
||||
showSearch
|
||||
modelName="outgoingWebhook2Store"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Webhook"
|
||||
className={cx('select', 'control')}
|
||||
value={custom_webhook}
|
||||
onChange={this._getOnChangeHandler('custom_webhook')}
|
||||
getOptionLabel={(item: SelectableValue) => {
|
||||
const team = teamStore.items[outgoingWebhook2Store.items[item.value].team];
|
||||
return (
|
||||
<>
|
||||
<Text>{item.label} </Text>
|
||||
<TeamName team={team} size="small" />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
width={'auto'}
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
_getOnSelectChangeHandler = (field: string) => {
|
||||
return (option: SelectableValue) => {
|
||||
const { data, onChange = () => {} } = this.props;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { Tooltip, VerticalGroup } from '@grafana/ui';
|
|||
import cn from 'classnames/bind';
|
||||
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails';
|
||||
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
|
||||
import Tag from 'components/Tag/Tag';
|
||||
import Text from 'components/Text/Text';
|
||||
import { Schedule, ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
|
|
@ -40,7 +40,7 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
|
|||
<>
|
||||
<div className={cx('root')}>
|
||||
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
|
||||
<ScheduleCounter
|
||||
<StatusCounterBadgeWithTooltip
|
||||
type="link"
|
||||
addPadding
|
||||
count={schedule.number_of_escalation_chains}
|
||||
|
|
@ -60,7 +60,7 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
|
|||
)}
|
||||
|
||||
{schedule.warnings?.length > 0 && (
|
||||
<ScheduleCounter
|
||||
<StatusCounterBadgeWithTooltip
|
||||
type="warning"
|
||||
addPadding
|
||||
count={schedule.warnings.length}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import cn from 'classnames/bind';
|
|||
|
||||
import Text, { TextType } from 'components/Text/Text';
|
||||
|
||||
import styles from './ScheduleCounter.module.scss';
|
||||
import styles from './StatusCounterBadgeWithTooltip.module.scss';
|
||||
|
||||
interface ScheduleCounterProps {
|
||||
interface StatusCounterBadgeWithTooltipProps {
|
||||
type: Partial<TextType>;
|
||||
count: number;
|
||||
tooltipTitle: string;
|
||||
|
|
@ -23,7 +23,7 @@ const typeToIcon = {
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
|
||||
const StatusCounterBadgeWithTooltip: FC<StatusCounterBadgeWithTooltipProps> = (props) => {
|
||||
const { type, count, tooltipTitle, tooltipContent, onHover, addPadding } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -55,4 +55,4 @@ const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ScheduleCounter;
|
||||
export default StatusCounterBadgeWithTooltip;
|
||||
|
|
@ -90,6 +90,7 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
|
|||
teamStore={store.grafanaTeamStore}
|
||||
scheduleStore={store.scheduleStore}
|
||||
outgoingWebhookStore={store.outgoingWebhookStore}
|
||||
outgoingWebhook2Store={store.outgoingWebhook2Store}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface EscalationPolicy {
|
|||
to_time: string | null;
|
||||
notify_to_channel: Channel['id'] | null;
|
||||
custom_button_trigger: ActionDTO['id'] | null;
|
||||
custom_webhook: ActionDTO['id'] | null;
|
||||
notify_to_group: UserGroup['id'] | null;
|
||||
notify_schedule: Schedule['id'] | null;
|
||||
important: boolean | null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
export const getApiPathByPage = (page: string) => {
|
||||
return (
|
||||
{ outgoing_webhooks: 'custom_buttons', incidents: 'alertgroups', integrations: 'alert_receive_channels' }[page] ||
|
||||
page
|
||||
{
|
||||
outgoing_webhooks: 'custom_buttons',
|
||||
outgoing_webhooks_2: 'webhooks',
|
||||
incidents: 'alertgroups',
|
||||
integrations: 'alert_receive_channels',
|
||||
}[page] || page
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
|
|
@ -14,6 +13,9 @@ export class OutgoingWebhook2Store extends BaseStore {
|
|||
@observable.shallow
|
||||
searchResult: { [key: string]: Array<OutgoingWebhook2['id']> } = {};
|
||||
|
||||
@observable
|
||||
incidentFilters: any;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
@ -45,7 +47,6 @@ export class OutgoingWebhook2Store extends BaseStore {
|
|||
@action
|
||||
async updateItem(id: OutgoingWebhook2['id'], fromOrganization = false) {
|
||||
const response = await this.getById(id, false, fromOrganization);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: response,
|
||||
|
|
@ -75,10 +76,17 @@ export class OutgoingWebhook2Store extends BaseStore {
|
|||
|
||||
this.searchResult = {
|
||||
...this.searchResult,
|
||||
[key]: results.map((item: OutgoingWebhook) => item.id),
|
||||
[key]: results.map((item: OutgoingWebhook2) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async updateOutgoingWebhooks2Filters(params: any) {
|
||||
this.incidentFilters = params;
|
||||
|
||||
this.updateItems();
|
||||
}
|
||||
|
||||
getSearchResult(query = '') {
|
||||
if (!this.searchResult[query]) {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
|
||||
export interface OutgoingWebhook2 {
|
||||
authorization_header: string;
|
||||
data: string;
|
||||
|
|
@ -7,7 +9,7 @@ export interface OutgoingWebhook2 {
|
|||
last_run: string;
|
||||
name: string;
|
||||
password: string;
|
||||
team: null;
|
||||
team: GrafanaTeam['id'];
|
||||
trigger_type: number;
|
||||
trigger_type_name: string;
|
||||
url: string;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import Avatar from 'components/Avatar/Avatar';
|
|||
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
|
||||
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
|
||||
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
|
||||
import Table from 'components/Table/Table';
|
||||
import Text from 'components/Text/Text';
|
||||
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
|
||||
|
|
@ -306,7 +306,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
return (
|
||||
<HorizontalGroup>
|
||||
{item.number_of_escalation_chains > 0 && (
|
||||
<ScheduleCounter
|
||||
<StatusCounterBadgeWithTooltip
|
||||
type="link"
|
||||
count={item.number_of_escalation_chains}
|
||||
tooltipTitle="Used in escalations"
|
||||
|
|
@ -334,7 +334,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
)}
|
||||
|
||||
{item.warnings?.length > 0 && (
|
||||
<ScheduleCounter
|
||||
<StatusCounterBadgeWithTooltip
|
||||
type="warning"
|
||||
count={item.warnings.length}
|
||||
tooltipTitle="Warnings"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import { Alert, Button, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip';
|
||||
import Text from 'components/Text/Text';
|
||||
import UsersFilters from 'components/UsersFilters/UsersFilters';
|
||||
import UserSettings from 'containers/UserSettings/UserSettings';
|
||||
|
|
@ -359,10 +360,22 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Icon className={cx('warning-message-icon')} name="exclamation-triangle" />
|
||||
{texts.join(', ')}
|
||||
</div>
|
||||
<HorizontalGroup>
|
||||
<StatusCounterBadgeWithTooltip
|
||||
type="warning"
|
||||
count={texts.length}
|
||||
tooltipTitle="Warnings"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="none">
|
||||
{texts.map((warning, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
<OutgoingWebhooks query={query} />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('outgoing_webhooks_2')} exact>
|
||||
<OutgoingWebhooks2 />
|
||||
<OutgoingWebhooks2 query={query} />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('maintenance')} exact>
|
||||
<Maintenance query={query} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue