v1.3.10
This commit is contained in:
commit
83cb13e3bb
106 changed files with 1122 additions and 3132 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
|
@ -4,4 +4,4 @@
|
|||
CHANGELOG.md
|
||||
|
||||
/grafana-plugin @grafana/grafana-oncall-frontend
|
||||
/docs @grafana/docs-oncall
|
||||
/docs @grafana/docs-gops
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -5,17 +5,40 @@ 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).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.3.10 (2023-07-13)
|
||||
|
||||
### Added
|
||||
|
||||
- [Helm] Added ability to specify `resources` definition within the `wait-for-db` init container by @Shelestov7
|
||||
([#2501](https://github.com/grafana/oncall/pull/2501))
|
||||
- Added index on `started_at` column in `alerts_alertgroup` table. This substantially speeds up query used by the `check_escalation_finished_task`
|
||||
task. By @joeyorlando and @Konstantinov-Innokentii ([#2516](https://github.com/grafana/oncall/pull/2516)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Deprecated `/maintenance` web UI page. Maintenance is now handled at the integration level and can be performed
|
||||
within a single integration's page. by @Ukochka ([#2497](https://github.com/grafana/oncall/issues/2497))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug in the integration maintenance mode workflow where a user could not start/stop an integration's
|
||||
maintenance mode by @joeyorlando ([#2511](https://github.com/grafana/oncall/issues/2511))
|
||||
- Schedules: Long popup does not fit screen & buttons unreachable & objects outside of the popup [#1002](https://github.com/grafana/oncall/issues/1002)
|
||||
- New schedules white theme issues [#2356](https://github.com/grafana/oncall/issues/2356)
|
||||
|
||||
## v1.3.9 (2023-07-12)
|
||||
|
||||
### Added
|
||||
|
||||
- Bring new Jinja editor to webhooks ([2344](https://github.com/grafana/oncall/issues/2344))
|
||||
- Bring new Jinja editor to webhooks ([#2344](https://github.com/grafana/oncall/issues/2344))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add debounce on Select UI components to avoid making API search requests on each key-down event by
|
||||
@maskin25 ([#2466](https://github.com/grafana/oncall/pull/2466))
|
||||
- Fixed schedules slack notifications for deleted organizations ([#2493](https://github.com/grafana/oncall/pull/2493))
|
||||
- Make Direct paging integration configurable ([2483](https://github.com/grafana/oncall/pull/2483))
|
||||
|
||||
## v1.3.8 (2023-07-11)
|
||||
|
||||
|
|
@ -67,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Changed
|
||||
|
||||
- UI drawer updates for webhooks2 ([#2419](https://github.com/grafana/oncall/pull/2419))
|
||||
- Removed url from sms notification, changed format ([2317](https://github.com/grafana/oncall/pull/2317))
|
||||
- Removed url from sms notification, changed format ([#2317](https://github.com/grafana/oncall/pull/2317))
|
||||
|
||||
## v1.3.3 (2023-06-29)
|
||||
|
||||
|
|
|
|||
|
|
@ -222,12 +222,12 @@ Zvonok.com, complete the following steps:
|
|||
2. Create a public API key on the Profile->Settings page, and assign its value to `ZVONOK_API_KEY`.
|
||||
3. Create campaign and assign its ID value to `ZVONOK_CAMPAIGN_ID`.
|
||||
4. If you are planning to use pre-recorded audio instead of a speech synthesizer, you can copy the ID of the audio clip
|
||||
to the variable `ZVONOK_AUDIO_ID` (optional step).
|
||||
to the variable `ZVONOK_AUDIO_ID` (optional step).
|
||||
5. To make a call with a specific voice, you can set the `ZVONOK_SPEAKER_ID`.
|
||||
By default, the ID used is `Salli` (optional step).
|
||||
By default, the ID used is `Salli` (optional step).
|
||||
6. To process the call status, it is required to add a postback with the GET/POST method on the side of the zvonok.com
|
||||
service with the following format (optional step):
|
||||
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
|
||||
service with the following format (optional step):
|
||||
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
|
||||
|
||||
The names of the transmitted parameters can be redefined through environment variables:
|
||||
|
||||
|
|
@ -312,3 +312,6 @@ To configure this feature as such:
|
|||
task runs every 13 minutes so we therefore recommend setting the heartbeat's expected time interval to 15 minutes. If you
|
||||
would like to modify this, we recommend configuring this env variable to 1 or 2 minutes less than the value set for the
|
||||
integration's heartbeat expected time interval.
|
||||
|
||||
Additionally, if you prefer to disable this feature, you can set the `ESCALATION_AUDITOR_ENABLED` environment variable
|
||||
to `False`.
|
||||
|
|
|
|||
|
|
@ -231,9 +231,7 @@ class EscalationSnapshotMixin:
|
|||
"""
|
||||
AlertGroup = apps.get_model("alerts", "AlertGroup")
|
||||
|
||||
is_on_maintenace_or_debug_mode = (
|
||||
self.channel.maintenance_mode is not None or self.channel.organization.maintenance_mode is not None
|
||||
)
|
||||
is_on_maintenace_or_debug_mode = self.channel.maintenance_mode is not None
|
||||
|
||||
if (
|
||||
self.is_restricted
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-13 06:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0020_auto_20230711_1532'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='alertgroup',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -128,10 +128,8 @@ class Alert(models.Model):
|
|||
if group_created:
|
||||
# all code below related to maintenance mode
|
||||
maintenance_uuid = None
|
||||
if alert_receive_channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
|
||||
maintenance_uuid = alert_receive_channel.organization.maintenance_uuid
|
||||
|
||||
elif alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
|
||||
if alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
|
||||
maintenance_uuid = alert_receive_channel.maintenance_uuid
|
||||
|
||||
if maintenance_uuid is not None:
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
acknowledged_by_confirmed = models.DateTimeField(null=True, default=None)
|
||||
|
||||
is_escalation_finished = models.BooleanField(default=False)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
slack_message_sent = models.BooleanField(default=False)
|
||||
|
||||
|
|
@ -426,7 +426,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
return self.maintenance_uuid is not None
|
||||
|
||||
def stop_maintenance(self, user: User) -> None:
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
|
||||
try:
|
||||
|
|
@ -436,13 +435,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
except AlertReceiveChannel.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
organization_on_maintenance = Organization.objects.get(maintenance_uuid=self.maintenance_uuid)
|
||||
organization_on_maintenance.force_disable_maintenance(user)
|
||||
return
|
||||
except Organization.DoesNotExist:
|
||||
pass
|
||||
|
||||
self.resolve_by_disable_maintenance()
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def _trigger_alert(
|
|||
deleted_at=None,
|
||||
defaults={
|
||||
"author": from_user,
|
||||
"verbal_name": f"Direct paging ({team.name if team else 'General'} team)",
|
||||
"verbal_name": "Direct paging",
|
||||
},
|
||||
)
|
||||
if alert_receive_channel.default_channel_filter is None:
|
||||
|
|
@ -149,8 +149,6 @@ def direct_paging(
|
|||
Otherwise, create a new alert using given title and message.
|
||||
|
||||
"""
|
||||
if not users and not schedules and not escalation_chain:
|
||||
return
|
||||
|
||||
if users is None:
|
||||
users = []
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import datetime
|
||||
import typing
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.apps import apps
|
||||
|
|
@ -96,38 +95,27 @@ def audit_alert_group_escalation(alert_group: "AlertGroup") -> None:
|
|||
task_logger.info(f"{base_msg} passed the audit checks")
|
||||
|
||||
|
||||
def get_auditable_alert_groups_started_at_range() -> typing.Tuple[datetime.datetime, datetime.datetime]:
|
||||
"""
|
||||
NOTE: this started_at__range is a bit of a hack..
|
||||
we wanted to avoid performing a migration on the alerts_alertgroup table to update
|
||||
alert groups where raw_escalation_snapshot was None. raw_escalation_snapshot being None is a legitimate case,
|
||||
where the alert group's integration does not have an escalation chain associated with it.
|
||||
|
||||
However, we wanted a way to be able to differentiate between "actually None" and "there was an error writing to
|
||||
raw_escalation_snapshot" (as this is performed async by a celery task).
|
||||
|
||||
This field was updated, in the commit that added this comment, to no longer be set to None by default.
|
||||
As part of this celery task we do a check that this field is in fact not None, so if we were to check older
|
||||
alert groups, whose integration did not have an escalation chain at the time the alert group was created
|
||||
we would raise errors
|
||||
"""
|
||||
return (datetime.datetime(2023, 3, 25, tzinfo=pytz.UTC), timezone.now() - datetime.timedelta(days=2))
|
||||
|
||||
|
||||
# don't retry this task as the AlertGroup DB query is rather expensive
|
||||
@shared_task
|
||||
def check_escalation_finished_task() -> None:
|
||||
"""
|
||||
don't retry this task, the idea is to be alerted of failures
|
||||
"""
|
||||
AlertGroup = apps.get_model("alerts", "AlertGroup")
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
|
||||
now = timezone.now()
|
||||
two_days_ago = now - datetime.timedelta(days=2)
|
||||
|
||||
alert_groups = AlertGroup.all_objects.using(get_random_readonly_database_key_if_present_otherwise_default()).filter(
|
||||
~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE),
|
||||
~Q(silenced=True, silenced_until__isnull=True), # filter silenced forever alert_groups
|
||||
# here we should query maintenance_uuid rather than joining on channel__integration
|
||||
# and checking for something like ~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE)
|
||||
# this avoids an unnecessary join
|
||||
maintenance_uuid__isnull=True,
|
||||
is_escalation_finished=False,
|
||||
resolved=False,
|
||||
acknowledged=False,
|
||||
root_alert_group=None,
|
||||
started_at__range=get_auditable_alert_groups_started_at_range(),
|
||||
started_at__range=(two_days_ago, now),
|
||||
)
|
||||
|
||||
if not alert_groups.exists():
|
||||
|
|
|
|||
|
|
@ -46,11 +46,8 @@ def send_alert_create_signal(alert_id):
|
|||
|
||||
task_logger.debug(f"Started send_alert_create_signal task for alert {alert_id}")
|
||||
alert = Alert.objects.get(pk=alert_id)
|
||||
is_on_maintenace_mode = (
|
||||
alert.group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
)
|
||||
if not is_on_maintenace_mode:
|
||||
|
||||
if alert.group.channel.maintenance_mode != AlertReceiveChannel.MAINTENANCE:
|
||||
alert_create_signal.send(
|
||||
sender=send_alert_create_signal,
|
||||
alert=alert_id,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from .task_logger import task_logger
|
|||
def disable_maintenance(*args, **kwargs):
|
||||
AlertGroup = apps.get_model("alerts", "AlertGroup")
|
||||
User = apps.get_model("user_management", "User")
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
user = None
|
||||
object_under_maintenance = None
|
||||
user_id = kwargs.get("user_id")
|
||||
|
|
@ -37,12 +36,6 @@ def disable_maintenance(*args, **kwargs):
|
|||
task_logger.info(
|
||||
f"AlertReceiveChannel for disable_maintenance does not exists. Id: {alert_receive_channel_id}"
|
||||
)
|
||||
elif "organization_id" in kwargs:
|
||||
organization_id = kwargs["organization_id"]
|
||||
try:
|
||||
object_under_maintenance = Organization.objects.select_for_update().get(pk=organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
task_logger.info(f"Organization for disable_maintenance does not exists. Id: {organization_id}")
|
||||
|
||||
else:
|
||||
task_logger.info(f"Invalid instance id passed in disable_maintenance. Got: {kwargs}")
|
||||
|
|
@ -90,7 +83,6 @@ def disable_maintenance(*args, **kwargs):
|
|||
)
|
||||
def check_maintenance_finished(*args, **kwargs):
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
now = timezone.now()
|
||||
maintenance_finish_at = ExpressionWrapper(
|
||||
(F("maintenance_started_at") + F("maintenance_duration")), output_field=fields.DateTimeField()
|
||||
|
|
@ -107,15 +99,3 @@ def check_maintenance_finished(*args, **kwargs):
|
|||
args=(),
|
||||
kwargs={"alert_receive_channel_id": id, "force": True},
|
||||
)
|
||||
|
||||
organization_with_expired_maintenance_ids = (
|
||||
Organization.objects.filter(maintenance_started_at__isnull=False)
|
||||
.annotate(maintenance_finish_at=maintenance_finish_at)
|
||||
.filter(maintenance_finish_at__lt=now)
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
for id in organization_with_expired_maintenance_ids:
|
||||
disable_maintenance.apply_async(
|
||||
args=(),
|
||||
kwargs={"organization_id": id, "force": True},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ def send_update_log_report_signal(log_record_pk=None, alert_group_pk=None):
|
|||
)
|
||||
return
|
||||
|
||||
is_on_maintenace_mode = (
|
||||
alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
or alert_group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
)
|
||||
if is_on_maintenace_mode:
|
||||
if alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
|
||||
task_logger.debug(
|
||||
f'send_update_log_report_signal: alert_group={alert_group_pk} msg="skip alert_group_update_log_report_signal due to maintenace"'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
from unittest.mock import Mock, PropertyMock, patch
|
||||
from unittest.mock import Mock, PropertyMock, call, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.alerts.tasks.check_escalation_finished import (
|
||||
AlertGroupEscalationPolicyExecutionAuditException,
|
||||
audit_alert_group_escalation,
|
||||
|
|
@ -15,40 +14,70 @@ from apps.alerts.tasks.check_escalation_finished import (
|
|||
|
||||
MOCKED_HEARTBEAT_URL = "https://hello.com/lsdjjkf"
|
||||
|
||||
|
||||
# def _get_relevant_log_record_type() -> int:
|
||||
# return random.choice([AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED, AlertGroupLogRecord.TYPE_ESCALATION_FAILED])
|
||||
now = timezone.now()
|
||||
yesterday = now - timezone.timedelta(days=1)
|
||||
|
||||
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_does_not_call_the_heartbeat_url_if_one_is_not_configured():
|
||||
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
|
||||
send_alert_group_escalation_auditor_task_heartbeat()
|
||||
mock_requests.get.assert_not_called()
|
||||
@pytest.fixture
|
||||
def make_alert_group_that_started_at_specific_date(make_alert_group):
|
||||
def _make_alert_group_that_started_at_specific_date(alert_receive_channel, started_at=yesterday, **kwargs):
|
||||
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
|
||||
alert_group = make_alert_group(alert_receive_channel, **kwargs)
|
||||
alert_group.started_at = started_at
|
||||
alert_group.save()
|
||||
|
||||
return alert_group
|
||||
|
||||
return _make_alert_group_that_started_at_specific_date
|
||||
|
||||
|
||||
def assert_not_called_with(self, *args, **kwargs):
|
||||
"""
|
||||
https://stackoverflow.com/a/54838760
|
||||
"""
|
||||
try:
|
||||
self.assert_called_with(*args, **kwargs)
|
||||
except AssertionError:
|
||||
return
|
||||
raise AssertionError("Expected %s to not have been called." % self._format_mock_call_signature(args, kwargs))
|
||||
|
||||
|
||||
Mock.assert_not_called_with = assert_not_called_with
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.requests")
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_does_not_call_the_heartbeat_url_if_one_is_not_configured(
|
||||
mock_requests,
|
||||
):
|
||||
send_alert_group_escalation_auditor_task_heartbeat()
|
||||
mock_requests.get.assert_not_called()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.requests")
|
||||
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_calls_the_heartbeat_url_if_one_is_configured():
|
||||
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_calls_the_heartbeat_url_if_one_is_configured(mock_requests):
|
||||
send_alert_group_escalation_auditor_task_heartbeat()
|
||||
|
||||
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
|
||||
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.requests")
|
||||
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_raises_an_exception_if_the_heartbeat_url_request_fails(
|
||||
mock_requests,
|
||||
):
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError
|
||||
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
with pytest.raises(requests.exceptions.HTTPError):
|
||||
send_alert_group_escalation_auditor_task_heartbeat()
|
||||
|
||||
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
|
||||
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
@override_settings(ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL=MOCKED_HEARTBEAT_URL)
|
||||
def test_send_alert_group_escalation_auditor_task_heartbeat_raises_an_exception_if_the_heartbeat_url_request_fails():
|
||||
with patch("apps.alerts.tasks.check_escalation_finished.requests") as mock_requests:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError
|
||||
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
with pytest.raises(requests.exceptions.HTTPError):
|
||||
send_alert_group_escalation_auditor_task_heartbeat()
|
||||
|
||||
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
|
||||
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
|
||||
mock_requests.get.assert_called_once_with(MOCKED_HEARTBEAT_URL)
|
||||
mock_requests.get.return_value.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -75,6 +104,7 @@ def test_audit_alert_group_escalation_skips_further_validation_if_the_escalation
|
|||
audit_alert_group_escalation(alert_group)
|
||||
|
||||
|
||||
@patch("apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.next_step_eta_is_valid")
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"next_step_eta_is_valid_return_value,raises_exception",
|
||||
|
|
@ -85,38 +115,36 @@ def test_audit_alert_group_escalation_skips_further_validation_if_the_escalation
|
|||
],
|
||||
)
|
||||
def test_audit_alert_group_escalation_next_step_eta_validation(
|
||||
escalation_snapshot_test_setup, next_step_eta_is_valid_return_value, raises_exception
|
||||
mock_next_step_eta_is_valid, escalation_snapshot_test_setup, next_step_eta_is_valid_return_value, raises_exception
|
||||
):
|
||||
mock_next_step_eta_is_valid.return_value = next_step_eta_is_valid_return_value
|
||||
alert_group, _, _, _ = escalation_snapshot_test_setup
|
||||
|
||||
if raises_exception:
|
||||
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException):
|
||||
audit_alert_group_escalation(alert_group)
|
||||
else:
|
||||
try:
|
||||
audit_alert_group_escalation(alert_group)
|
||||
except AlertGroupEscalationPolicyExecutionAuditException:
|
||||
pytest.fail()
|
||||
|
||||
mock_next_step_eta_is_valid.assert_called_once_with()
|
||||
|
||||
|
||||
@patch(
|
||||
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.executed_escalation_policy_snapshots",
|
||||
new_callable=PropertyMock,
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(
|
||||
mock_executed_escalation_policy_snapshots, escalation_snapshot_test_setup
|
||||
):
|
||||
alert_group, _, _, _ = escalation_snapshot_test_setup
|
||||
|
||||
with patch(
|
||||
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.next_step_eta_is_valid"
|
||||
) as mock_next_step_eta_is_valid:
|
||||
mock_next_step_eta_is_valid.return_value = next_step_eta_is_valid_return_value
|
||||
|
||||
if raises_exception:
|
||||
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException):
|
||||
audit_alert_group_escalation(alert_group)
|
||||
else:
|
||||
try:
|
||||
audit_alert_group_escalation(alert_group)
|
||||
except AlertGroupEscalationPolicyExecutionAuditException:
|
||||
pytest.fail()
|
||||
|
||||
mock_next_step_eta_is_valid.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(escalation_snapshot_test_setup):
|
||||
alert_group, _, _, _ = escalation_snapshot_test_setup
|
||||
|
||||
with patch(
|
||||
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_snapshot.EscalationSnapshot.executed_escalation_policy_snapshots",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_executed_escalation_policy_snapshots:
|
||||
mock_executed_escalation_policy_snapshots.return_value = []
|
||||
audit_alert_group_escalation(alert_group)
|
||||
mock_executed_escalation_policy_snapshots.assert_called_once_with()
|
||||
mock_executed_escalation_policy_snapshots.return_value = []
|
||||
audit_alert_group_escalation(alert_group)
|
||||
mock_executed_escalation_policy_snapshots.assert_called_once_with()
|
||||
|
||||
|
||||
# # see TODO: comment in engine/apps/alerts/tasks/check_escalation_finished.py
|
||||
|
|
@ -174,156 +202,151 @@ def test_audit_alert_group_escalation_no_executed_escalation_policy_snapshots(es
|
|||
# mock_executed_escalation_policy_snapshots.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
|
||||
@pytest.mark.django_db
|
||||
def test_check_escalation_finished_task_queries_doesnt_grab_alert_groups_outside_of_date_range(
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat,
|
||||
mocked_audit_alert_group_escalation,
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert_group_that_started_at_specific_date,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
now = timezone.now()
|
||||
two_days_ago = now - timezone.timedelta(days=2)
|
||||
two_days_in_future = now + timezone.timedelta(days=2)
|
||||
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
make_alert_group_that_started_at_specific_date(alert_receive_channel, now - timezone.timedelta(days=5))
|
||||
make_alert_group_that_started_at_specific_date(alert_receive_channel, now + timezone.timedelta(days=5))
|
||||
|
||||
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
|
||||
alert_group1 = make_alert_group(alert_receive_channel)
|
||||
alert_group1.started_at = now
|
||||
check_escalation_finished_task()
|
||||
|
||||
alert_group2 = make_alert_group(alert_receive_channel)
|
||||
alert_group2.started_at = now - timezone.timedelta(days=5)
|
||||
|
||||
alert_group3 = make_alert_group(alert_receive_channel)
|
||||
alert_group3.started_at = now + timezone.timedelta(days=5)
|
||||
|
||||
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
|
||||
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
|
||||
) as mocked_get_auditable_alert_groups_started_at_range:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
|
||||
) as mocked_audit_alert_group_escalation:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
|
||||
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
|
||||
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
|
||||
|
||||
check_escalation_finished_task()
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_called_once_with(alert_group1)
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
mocked_audit_alert_group_escalation.assert_called_once_with(alert_group1)
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
|
||||
@pytest.mark.django_db
|
||||
def test_check_escalation_finished_task_calls_audit_alert_group_escalation_for_every_alert_group(
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat,
|
||||
mocked_audit_alert_group_escalation,
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert_group_that_started_at_specific_date,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
now = timezone.now()
|
||||
two_days_ago = now - timezone.timedelta(days=2)
|
||||
two_days_in_future = now + timezone.timedelta(days=2)
|
||||
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
alert_group2 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
alert_group3 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
|
||||
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
|
||||
alert_group1 = make_alert_group(alert_receive_channel)
|
||||
alert_group1.started_at = now
|
||||
check_escalation_finished_task()
|
||||
|
||||
alert_group2 = make_alert_group(alert_receive_channel)
|
||||
alert_group2.started_at = now
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
|
||||
|
||||
alert_group3 = make_alert_group(alert_receive_channel)
|
||||
alert_group3.started_at = now
|
||||
|
||||
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
|
||||
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
|
||||
) as mocked_get_auditable_alert_groups_started_at_range:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
|
||||
) as mocked_audit_alert_group_escalation:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
|
||||
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
|
||||
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
|
||||
|
||||
check_escalation_finished_task()
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
|
||||
@pytest.mark.django_db
|
||||
def test_check_escalation_finished_task_simply_calls_heartbeat_when_no_alert_groups_found():
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
|
||||
) as mocked_audit_alert_group_escalation:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
|
||||
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
|
||||
check_escalation_finished_task()
|
||||
mocked_audit_alert_group_escalation.assert_not_called()
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
def test_check_escalation_finished_task_filters_the_right_alert_groups(
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat,
|
||||
mocked_audit_alert_group_escalation,
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group_that_started_at_specific_date,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
|
||||
silenced_for_one_hour_alert_group = make_alert_group_that_started_at_specific_date(
|
||||
alert_receive_channel, silenced=True, silenced_until=(now + timezone.timedelta(hours=1))
|
||||
)
|
||||
silenced_forever = make_alert_group_that_started_at_specific_date(
|
||||
alert_receive_channel, silenced=True, silenced_until=None
|
||||
)
|
||||
|
||||
in_maintenance = make_alert_group_that_started_at_specific_date(alert_receive_channel, maintenance_uuid="asdfasdf")
|
||||
escalation_finished = make_alert_group_that_started_at_specific_date(
|
||||
alert_receive_channel, is_escalation_finished=True
|
||||
)
|
||||
|
||||
resolved = make_alert_group_that_started_at_specific_date(alert_receive_channel, is_escalation_finished=True)
|
||||
acknowledged = make_alert_group_that_started_at_specific_date(alert_receive_channel, is_escalation_finished=True)
|
||||
|
||||
root_alert_group = make_alert_group_that_started_at_specific_date(
|
||||
alert_receive_channel, root_alert_group=alert_group1
|
||||
)
|
||||
|
||||
check_escalation_finished_task()
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_has_calls(
|
||||
[
|
||||
call(alert_group1),
|
||||
call(silenced_for_one_hour_alert_group),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(in_maintenance)
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(escalation_finished)
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(silenced_forever)
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(resolved)
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(acknowledged)
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_not_called_with(root_alert_group)
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
|
||||
@pytest.mark.django_db
|
||||
def test_check_escalation_finished_task_simply_calls_heartbeat_when_no_alert_groups_found(
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat,
|
||||
mocked_audit_alert_group_escalation,
|
||||
):
|
||||
check_escalation_finished_task()
|
||||
mocked_audit_alert_group_escalation.assert_not_called()
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation")
|
||||
@patch("apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat")
|
||||
@pytest.mark.django_db
|
||||
def test_check_escalation_finished_task_calls_audit_alert_group_escalation_for_every_alert_group_even_if_one_fails(
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat,
|
||||
mocked_audit_alert_group_escalation,
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert_group_that_started_at_specific_date,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
now = timezone.now()
|
||||
two_days_ago = now - timezone.timedelta(days=2)
|
||||
two_days_in_future = now + timezone.timedelta(days=2)
|
||||
|
||||
# we can't simply pass started_at to the fixture because started_at is being "auto-set" on the Model
|
||||
alert_group1 = make_alert_group(alert_receive_channel)
|
||||
alert_group1.started_at = now
|
||||
|
||||
alert_group2 = make_alert_group(alert_receive_channel)
|
||||
alert_group2.started_at = now
|
||||
|
||||
alert_group3 = make_alert_group(alert_receive_channel)
|
||||
alert_group3.started_at = now
|
||||
|
||||
AlertGroup.all_objects.bulk_update([alert_group1, alert_group2, alert_group3], ["started_at"])
|
||||
alert_group1 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
alert_group2 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
alert_group3 = make_alert_group_that_started_at_specific_date(alert_receive_channel)
|
||||
|
||||
def _mocked_audit_alert_group_escalation(alert_group):
|
||||
if not alert_group.id == alert_group3.id:
|
||||
raise AlertGroupEscalationPolicyExecutionAuditException("asdfasdf")
|
||||
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.get_auditable_alert_groups_started_at_range"
|
||||
) as mocked_get_auditable_alert_groups_started_at_range:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.audit_alert_group_escalation"
|
||||
) as mocked_audit_alert_group_escalation:
|
||||
with patch(
|
||||
"apps.alerts.tasks.check_escalation_finished.send_alert_group_escalation_auditor_task_heartbeat"
|
||||
) as mocked_send_alert_group_escalation_auditor_task_heartbeat:
|
||||
mocked_get_auditable_alert_groups_started_at_range.return_value = (two_days_ago, two_days_in_future)
|
||||
mocked_audit_alert_group_escalation.side_effect = _mocked_audit_alert_group_escalation
|
||||
mocked_audit_alert_group_escalation.side_effect = _mocked_audit_alert_group_escalation
|
||||
|
||||
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException) as exc:
|
||||
check_escalation_finished_task()
|
||||
with pytest.raises(AlertGroupEscalationPolicyExecutionAuditException) as exc:
|
||||
check_escalation_finished_task()
|
||||
|
||||
assert (
|
||||
str(exc.value)
|
||||
== f"The following alert group id(s) failed auditing: {alert_group1.id}, {alert_group2.id}"
|
||||
)
|
||||
assert str(exc.value) == f"The following alert group id(s) failed auditing: {alert_group1.id}, {alert_group2.id}"
|
||||
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group1)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group2)
|
||||
mocked_audit_alert_group_escalation.assert_any_call(alert_group3)
|
||||
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_not_called()
|
||||
mocked_send_alert_group_escalation_auditor_task_heartbeat.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -86,40 +86,6 @@ def test_maintenance_integration_will_not_start_twice(
|
|||
assert alert_receive_channel.maintenance_author == user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_maintenance_team(maintenance_test_setup, mock_start_disable_maintenance_task):
|
||||
organization, user = maintenance_test_setup
|
||||
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
|
||||
assert organization.maintenance_mode == mode
|
||||
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
|
||||
assert organization.maintenance_uuid is not None
|
||||
assert organization.maintenance_started_at is not None
|
||||
assert organization.maintenance_author == user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_maintenance_team_will_not_start_twice(maintenance_test_setup, mock_start_disable_maintenance_task):
|
||||
organization, user = maintenance_test_setup
|
||||
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
with pytest.raises(MaintenanceCouldNotBeStartedError):
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
|
||||
assert organization.maintenance_mode == mode
|
||||
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
|
||||
assert organization.maintenance_uuid is not None
|
||||
assert organization.maintenance_started_at is not None
|
||||
assert organization.maintenance_author == user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_attached_to_maintenance_incident_integration(
|
||||
maintenance_test_setup,
|
||||
|
|
@ -151,38 +117,6 @@ def test_alert_attached_to_maintenance_incident_integration(
|
|||
assert alert.group.root_alert_group == maintenance_incident
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_attached_to_maintenance_incident_team(
|
||||
maintenance_test_setup,
|
||||
make_alert_receive_channel,
|
||||
make_alert_with_custom_create_method,
|
||||
mock_start_disable_maintenance_task,
|
||||
):
|
||||
organization, user = maintenance_test_setup
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
maintenance_incident = AlertGroup.all_objects.get(maintenance_uuid=organization.maintenance_uuid)
|
||||
|
||||
alert = make_alert_with_custom_create_method(
|
||||
title="test_title",
|
||||
message="test_message",
|
||||
image_url="test_img_url",
|
||||
link_to_upstream_details=None,
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={"message": "test"},
|
||||
integration_unique_data={},
|
||||
)
|
||||
|
||||
assert alert.group.root_alert_group == maintenance_incident
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_stop_maintenance(
|
||||
maintenance_test_setup,
|
||||
|
|
@ -214,9 +148,3 @@ def test_stop_maintenance(
|
|||
alert.refresh_from_db()
|
||||
assert maintenance_incident.resolved_by == AlertGroup.DISABLE_MAINTENANCE
|
||||
assert alert.group.resolved_by == AlertGroup.DISABLE_MAINTENANCE
|
||||
|
||||
assert organization.maintenance_mode is None
|
||||
assert organization.maintenance_duration is None
|
||||
assert organization.maintenance_uuid is None
|
||||
assert organization.maintenance_started_at is None
|
||||
assert organization.maintenance_author is None
|
||||
|
|
|
|||
|
|
@ -154,20 +154,6 @@ def test_check_user_availability_on_call(
|
|||
assert warnings == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_no_one(make_organization, make_user_for_organization):
|
||||
organization = make_organization()
|
||||
from_user = make_user_for_organization(organization)
|
||||
|
||||
with patch("apps.alerts.paging.notify_user_task") as notify_task:
|
||||
direct_paging(organization, None, from_user)
|
||||
|
||||
# no alert group
|
||||
assert AlertGroup.all_objects.count() == 0
|
||||
# no notifications
|
||||
assert not notify_task.apply_async.called
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_user(make_organization, make_user_for_organization):
|
||||
organization = make_organization()
|
||||
|
|
|
|||
|
|
@ -117,8 +117,15 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
|
|||
connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization)
|
||||
if connection_error:
|
||||
raise BadRequest(detail=connection_error)
|
||||
for _integration in AlertReceiveChannel._config:
|
||||
if _integration.slug == integration:
|
||||
is_able_to_autoresolve = _integration.is_able_to_autoresolve
|
||||
|
||||
instance = AlertReceiveChannel.create(
|
||||
**validated_data, organization=organization, author=self.context["request"].user
|
||||
**validated_data,
|
||||
organization=organization,
|
||||
author=self.context["request"].user,
|
||||
allow_source_based_resolving=is_able_to_autoresolve,
|
||||
)
|
||||
|
||||
return instance
|
||||
|
|
@ -172,8 +179,11 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
|
|||
return obj.channel_filters.count()
|
||||
|
||||
def get_connected_escalations_chains_count(self, obj) -> int:
|
||||
return len(
|
||||
set(ChannelFilter.objects.filter(alert_receive_channel=obj).values_list("escalation_chain", flat=True))
|
||||
return (
|
||||
ChannelFilter.objects.filter(alert_receive_channel=obj, escalation_chain__isnull=False)
|
||||
.values("escalation_chain")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -192,8 +202,7 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
|
|||
fields = ["id", "integration", "verbal_name", "deleted"]
|
||||
|
||||
def get_deleted(self, obj):
|
||||
# Treat direct paging integrations as deleted, so integration settings are disabled on the frontend
|
||||
return obj.deleted_at is not None or obj.integration == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
return obj.deleted_at is not None
|
||||
|
||||
|
||||
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
slack_team_identity = FastSlackTeamIdentitySerializer(read_only=True)
|
||||
|
||||
name = serializers.CharField(required=False, allow_null=True, allow_blank=True, source="org_title")
|
||||
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
|
||||
slack_channel = serializers.SerializerMethodField()
|
||||
|
||||
SELECT_RELATED = ["slack_team_identity"]
|
||||
|
|
@ -32,14 +31,10 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
"pk",
|
||||
"name",
|
||||
"slack_team_identity",
|
||||
"maintenance_mode",
|
||||
"maintenance_till",
|
||||
"slack_channel",
|
||||
]
|
||||
read_only_fields = [
|
||||
"slack_team_identity",
|
||||
"maintenance_mode",
|
||||
"maintenance_till",
|
||||
]
|
||||
|
||||
def get_slack_channel(self, obj):
|
||||
|
|
|
|||
|
|
@ -56,17 +56,12 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
def validate(self, attrs):
|
||||
organization = self.context["organization"]
|
||||
|
||||
users = attrs["users"]
|
||||
schedules = attrs["schedules"]
|
||||
escalation_chain_id = attrs["escalation_chain_id"]
|
||||
|
||||
alert_group_id = attrs["alert_group_id"]
|
||||
title = attrs["title"]
|
||||
message = attrs["message"]
|
||||
|
||||
if len(users) == 0 and len(schedules) == 0 and not escalation_chain_id:
|
||||
raise serializers.ValidationError("Provide users, schedules, or an escalation chain")
|
||||
|
||||
if alert_group_id and (title or message):
|
||||
raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord
|
||||
from apps.api.errors import AlertGroupAPIError
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
|
|
@ -1813,28 +1813,6 @@ def test_alert_group_paged_users(
|
|||
assert response.json()["paged_users"] == [user2.short()]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_integration_treated_as_deleted(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
alert_group_internal_api_setup,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": alert_group.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.json()["alert_receive_channel"]["deleted"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_resolve_resolution_note(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
|
|
|
|||
|
|
@ -675,23 +675,6 @@ def test_alert_receive_channel_counters_per_integration_permissions(
|
|||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_alert_receive_channels_direct_paging_hidden_from_list(
|
||||
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
make_alert_receive_channel(user.organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_receive_channel-list")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
# Check no direct paging integrations in the response
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 0
|
||||
assert len(response.json()["results"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_alert_receive_channels_direct_paging_present_for_filters(
|
||||
make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_user_auth_headers
|
||||
|
|
|
|||
|
|
@ -96,7 +96,9 @@ def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup,
|
|||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["step"] == EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS
|
||||
assert response.json()["notify_to_users_queue"] == [first_user.public_primary_key, second_user.public_primary_key]
|
||||
assert sorted(response.json()["notify_to_users_queue"]) == sorted(
|
||||
[first_user.public_primary_key, second_user.public_primary_key]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -1,272 +0,0 @@
|
|||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
# TODO: should probably modify these tests to take into account new rbac permissions
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def maintenance_internal_api_setup(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_escalation_chain,
|
||||
make_alert_receive_channel,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
make_escalation_chain(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
return token, organization, user, alert_receive_channel
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_maintenance_integration(
|
||||
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
|
||||
):
|
||||
token, _, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:start_maintenance")
|
||||
data = {
|
||||
"mode": AlertReceiveChannel.MAINTENANCE,
|
||||
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
|
||||
"type": "alert_receive_channel",
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
|
||||
assert alert_receive_channel.maintenance_uuid is not None
|
||||
assert alert_receive_channel.maintenance_started_at is not None
|
||||
assert alert_receive_channel.maintenance_author is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_maintenance_integration_user_team(
|
||||
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
|
||||
):
|
||||
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
another_team = make_team(organization)
|
||||
user.current_team = another_team
|
||||
user.save()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:start_maintenance")
|
||||
data = {
|
||||
"mode": AlertReceiveChannel.MAINTENANCE,
|
||||
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
|
||||
"type": "alert_receive_channel",
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
|
||||
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
|
||||
assert alert_receive_channel.maintenance_uuid is not None
|
||||
assert alert_receive_channel.maintenance_started_at is not None
|
||||
assert alert_receive_channel.maintenance_author is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_maintenance_integration_different_team(
|
||||
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
|
||||
):
|
||||
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
another_team = make_team(organization)
|
||||
other_team = make_team(organization)
|
||||
user.current_team = another_team
|
||||
user.save()
|
||||
# integration belongs to non-general team, != user current team
|
||||
alert_receive_channel.team = other_team
|
||||
alert_receive_channel.save()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:start_maintenance")
|
||||
data = {
|
||||
"mode": AlertReceiveChannel.MAINTENANCE,
|
||||
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
|
||||
"type": "alert_receive_channel",
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert alert_receive_channel.maintenance_mode is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_stop_maintenance_integration(
|
||||
maintenance_internal_api_setup,
|
||||
mock_start_disable_maintenance_task,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
token, _, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
alert_receive_channel.start_maintenance(mode, duration, user)
|
||||
url = reverse("api-internal:stop_maintenance")
|
||||
data = {
|
||||
"type": "alert_receive_channel",
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert alert_receive_channel.maintenance_mode is None
|
||||
assert alert_receive_channel.maintenance_duration is None
|
||||
assert alert_receive_channel.maintenance_uuid is None
|
||||
assert alert_receive_channel.maintenance_started_at is None
|
||||
assert alert_receive_channel.maintenance_author is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_maintenance_organization(
|
||||
maintenance_internal_api_setup,
|
||||
mock_start_disable_maintenance_task,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
token, organization, user, _ = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:start_maintenance")
|
||||
data = {
|
||||
"mode": Organization.MAINTENANCE,
|
||||
"duration": Organization.DURATION_ONE_HOUR.total_seconds(),
|
||||
"type": "organization",
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
organization.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert organization.maintenance_mode == Organization.MAINTENANCE
|
||||
assert organization.maintenance_duration == Organization.DURATION_ONE_HOUR
|
||||
assert organization.maintenance_uuid is not None
|
||||
assert organization.maintenance_started_at is not None
|
||||
assert organization.maintenance_author is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_stop_maintenance_team(
|
||||
maintenance_internal_api_setup,
|
||||
mock_start_disable_maintenance_task,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
token, organization, user, _ = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
mode = Organization.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
url = reverse("api-internal:stop_maintenance")
|
||||
data = {
|
||||
"type": "organization",
|
||||
}
|
||||
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
|
||||
organization.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert organization.maintenance_mode is None
|
||||
assert organization.maintenance_duration is None
|
||||
assert organization.maintenance_uuid is None
|
||||
assert organization.maintenance_started_at is None
|
||||
assert organization.maintenance_author is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_maintenances_list(
|
||||
maintenance_internal_api_setup,
|
||||
mock_start_disable_maintenance_task,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
alert_receive_channel.start_maintenance(mode, duration, user)
|
||||
organization.start_maintenance(mode, duration, user)
|
||||
url = reverse("api-internal:maintenance")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
expected_payload = [
|
||||
{
|
||||
"organization_id": organization.public_primary_key,
|
||||
"type": "organization",
|
||||
"maintenance_mode": 1,
|
||||
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
|
||||
"started_at_timestamp": organization.started_at_timestamp,
|
||||
},
|
||||
{
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
"type": "alert_receive_channel",
|
||||
"maintenance_mode": 1,
|
||||
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
|
||||
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
|
||||
},
|
||||
]
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_maintenances_list_include_all_user_teams(
|
||||
maintenance_internal_api_setup,
|
||||
mock_start_disable_maintenance_task,
|
||||
make_user_auth_headers,
|
||||
make_team,
|
||||
):
|
||||
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
another_team = make_team(organization)
|
||||
other_team = make_team(organization)
|
||||
# setup user teams
|
||||
user.teams.add(another_team)
|
||||
user.teams.add(other_team)
|
||||
user.current_team = other_team
|
||||
user.save()
|
||||
# integration belongs to non-general team, != user current team
|
||||
alert_receive_channel.team = another_team
|
||||
alert_receive_channel.save()
|
||||
|
||||
client = APIClient()
|
||||
mode = AlertReceiveChannel.MAINTENANCE
|
||||
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
|
||||
alert_receive_channel.start_maintenance(mode, duration, user)
|
||||
url = reverse("api-internal:maintenance")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
expected_payload = [
|
||||
{
|
||||
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
|
||||
"type": "alert_receive_channel",
|
||||
"maintenance_mode": 1,
|
||||
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
|
||||
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
|
||||
},
|
||||
]
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_empty_maintenances_list(
|
||||
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
|
||||
):
|
||||
token, _, user, alert_receive_channel = maintenance_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:maintenance")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
expected_payload = []
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_payload
|
||||
|
|
@ -15,7 +15,6 @@ from .views.features import FeaturesAPIView
|
|||
from .views.gitops import TerraformGitOpsView, TerraformStateView
|
||||
from .views.integration_heartbeat import IntegrationHeartBeatView
|
||||
from .views.live_setting import LiveSettingViewSet
|
||||
from .views.maintenance import MaintenanceAPIView, MaintenanceStartAPIView, MaintenanceStopAPIView
|
||||
from .views.on_call_shifts import OnCallShiftView
|
||||
from .views.organization import (
|
||||
CurrentOrganizationView,
|
||||
|
|
@ -84,9 +83,6 @@ urlpatterns = [
|
|||
),
|
||||
optional_slash_path("terraform_file", TerraformGitOpsView.as_view(), name="terraform_file"),
|
||||
optional_slash_path("terraform_imports", TerraformStateView.as_view(), name="terraform_imports"),
|
||||
optional_slash_path("maintenance", MaintenanceAPIView.as_view(), name="maintenance"),
|
||||
optional_slash_path("start_maintenance", MaintenanceStartAPIView.as_view(), name="start_maintenance"),
|
||||
optional_slash_path("stop_maintenance", MaintenanceStopAPIView.as_view(), name="stop_maintenance"),
|
||||
optional_slash_path("slack_settings", SlackTeamSettingsAPIView.as_view(), name="slack-settings"),
|
||||
optional_slash_path(
|
||||
"slack_settings/acknowledge_remind_options",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from apps.api.serializers.alert_receive_channel import (
|
|||
)
|
||||
from apps.api.throttlers import DemoAlertThrottler
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.user_management.models.team import Team
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.mixins import (
|
||||
|
|
@ -104,10 +105,39 @@ class AlertReceiveChannelView(
|
|||
}
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if request.data["integration"] is not None and (
|
||||
request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES
|
||||
):
|
||||
return super().create(request, *args, **kwargs)
|
||||
organization = request.auth.organization
|
||||
user = request.user
|
||||
team_lookup = {}
|
||||
if "team" in request.data:
|
||||
team_public_pk = request.data.get("team", None)
|
||||
if team_public_pk is not None:
|
||||
try:
|
||||
team = user.available_teams.get(public_primary_key=team_public_pk)
|
||||
team_lookup = {"team": team}
|
||||
except Team.DoesNotExist:
|
||||
return Response(data="invalid team", status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
team_lookup = {"team__isnull": True}
|
||||
|
||||
if request.data["integration"] is not None:
|
||||
if request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES:
|
||||
# Don't allow multiple Direct Paging integrations
|
||||
if request.data["integration"] == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING:
|
||||
try:
|
||||
AlertReceiveChannel.objects.get(
|
||||
organization=organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
||||
deleted_at=None,
|
||||
**team_lookup,
|
||||
)
|
||||
return Response(
|
||||
data="Direct paging integration already exists for this team",
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except AlertReceiveChannel.DoesNotExist:
|
||||
pass
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
return Response(data="invalid integration", status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
|
|
@ -147,10 +177,6 @@ class AlertReceiveChannelView(
|
|||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
||||
# Hide direct paging integrations from the list view, but not from the filters
|
||||
if not is_filters_request:
|
||||
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.alerts.models.maintainable_object import MaintainableObject
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import TeamFilteringMixin
|
||||
from common.exceptions import MaintenanceCouldNotBeStartedError
|
||||
|
||||
|
||||
class GetObjectMixin:
|
||||
def get_object(self, request):
|
||||
organization = request.auth.organization
|
||||
type = request.data.get("type", None)
|
||||
|
||||
if type == "organization":
|
||||
instance = organization
|
||||
elif type == "alert_receive_channel":
|
||||
pk = request.data.get("alert_receive_channel_id", None)
|
||||
if pk is not None:
|
||||
try:
|
||||
instance = AlertReceiveChannel.objects.get(
|
||||
public_primary_key=pk,
|
||||
organization=organization,
|
||||
)
|
||||
if instance.team is not None and instance.team not in self.request.user.teams.all():
|
||||
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
|
||||
except AlertReceiveChannel.DoesNotExist:
|
||||
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
|
||||
else:
|
||||
raise BadRequest(detail={"alert_receive_channel_id": ["id is required"]})
|
||||
else:
|
||||
raise BadRequest(detail={"type": ["Unknown type"]})
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class MaintenanceAPIView(APIView, TeamFilteringMixin):
|
||||
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
|
||||
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
|
||||
rbac_permissions = {
|
||||
"get": [RBACPermission.Permissions.MAINTENANCE_READ],
|
||||
"filters": [RBACPermission.Permissions.MAINTENANCE_READ],
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
organization = self.request.auth.organization
|
||||
|
||||
response = []
|
||||
integrations_under_maintenance = (
|
||||
AlertReceiveChannel.objects.filter(
|
||||
maintenance_mode__isnull=False, organization=organization, *self.available_teams_lookup_args
|
||||
)
|
||||
.distinct()
|
||||
.order_by("maintenance_started_at")
|
||||
)
|
||||
|
||||
if organization.maintenance_mode is not None:
|
||||
response.append(
|
||||
{
|
||||
"organization_id": organization.public_primary_key,
|
||||
"type": "organization",
|
||||
"maintenance_mode": organization.maintenance_mode,
|
||||
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
|
||||
"started_at_timestamp": organization.started_at_timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
for i in integrations_under_maintenance:
|
||||
response.append(
|
||||
{
|
||||
"alert_receive_channel_id": i.public_primary_key,
|
||||
"type": "alert_receive_channel",
|
||||
"maintenance_mode": i.maintenance_mode,
|
||||
"maintenance_till_timestamp": i.till_maintenance_timestamp,
|
||||
"started_at_timestamp": i.started_at_timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(response, status=200)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def filters(self, request):
|
||||
filter_name = request.query_params.get("search", None)
|
||||
api_root = "/api/internal/v1/"
|
||||
|
||||
filter_options = [
|
||||
{
|
||||
"name": "team",
|
||||
"type": "team_select",
|
||||
"href": api_root + "teams/",
|
||||
"global": True,
|
||||
},
|
||||
]
|
||||
|
||||
if filter_name is not None:
|
||||
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
|
||||
|
||||
return Response(filter_options)
|
||||
|
||||
|
||||
class MaintenanceStartAPIView(GetObjectMixin, APIView):
|
||||
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
|
||||
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
rbac_permissions = {
|
||||
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
mode = request.data.get("mode", None)
|
||||
duration = request.data.get("duration", None)
|
||||
try:
|
||||
mode = int(mode)
|
||||
except (ValueError, TypeError):
|
||||
raise BadRequest(detail={"mode": ["Invalid mode"]})
|
||||
if mode not in [MaintainableObject.DEBUG_MAINTENANCE, MaintainableObject.MAINTENANCE]:
|
||||
raise BadRequest(detail={"mode": ["Unknown mode"]})
|
||||
try:
|
||||
duration = int(duration)
|
||||
except (ValueError, TypeError):
|
||||
raise BadRequest(detail={"duration": ["Invalid duration"]})
|
||||
if duration not in MaintainableObject.maintenance_duration_options_in_seconds():
|
||||
raise BadRequest(detail={"mode": ["Unknown duration"]})
|
||||
|
||||
instance = self.get_object(request)
|
||||
try:
|
||||
instance.start_maintenance(mode, duration, request.user)
|
||||
except MaintenanceCouldNotBeStartedError as e:
|
||||
if type(instance) == AlertReceiveChannel:
|
||||
detail = {"alert_receive_channel_id": ["Already on maintenance"]}
|
||||
else:
|
||||
detail = str(e)
|
||||
raise BadRequest(detail=detail)
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class MaintenanceStopAPIView(GetObjectMixin, APIView):
|
||||
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
|
||||
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
rbac_permissions = {
|
||||
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
instance = self.get_object(request)
|
||||
user = request.user
|
||||
instance.force_disable_maintenance(user)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
|
@ -2,17 +2,11 @@ from rest_framework import serializers
|
|||
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
from .maintenance import MaintainableObjectSerializerMixin
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer, MaintainableObjectSerializerMixin):
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.ReadOnlyField(read_only=True, source="public_primary_key")
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = MaintainableObjectSerializerMixin.Meta.fields + [
|
||||
"id",
|
||||
]
|
||||
read_only_fields = MaintainableObjectSerializerMixin.Meta.fields + [
|
||||
"id",
|
||||
]
|
||||
fields = ["id"]
|
||||
read_only_fields = ["id"]
|
||||
|
|
|
|||
|
|
@ -726,25 +726,6 @@ def test_set_default_messaging_backend_template(
|
|||
assert response.data == expected_response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_list_integrations_direct_paging_hidden(
|
||||
make_organization_and_user_with_token,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_integration_heartbeat,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:integrations-list")
|
||||
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
|
||||
# Check no direct paging integrations in the response
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_list_integrations_link_and_inbound_email(
|
||||
make_organization_and_user_with_token,
|
||||
|
|
|
|||
|
|
@ -48,9 +48,6 @@ class IntegrationView(
|
|||
queryset = self.serializer_class.setup_eager_loading(queryset)
|
||||
queryset = queryset.annotate(alert_groups_count_annotated=Count("alert_groups", distinct=True))
|
||||
|
||||
# Hide direct paging integrations
|
||||
queryset = queryset.exclude(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
|
|
|
|||
|
|
@ -66,12 +66,7 @@ class AlertShootingStep(scenario_step.ScenarioStep):
|
|||
AlertGroup.all_objects.filter(pk=alert.group.pk).update(slack_message_sent=False)
|
||||
raise e
|
||||
|
||||
is_on_debug_mode = (
|
||||
alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
|
||||
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
|
||||
)
|
||||
|
||||
if is_on_debug_mode:
|
||||
if alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE:
|
||||
self._send_debug_mode_notice(alert.group, channel_id)
|
||||
|
||||
if alert.group.is_maintenance_incident:
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ from django.utils import timezone
|
|||
from mirage import fields as mirage_fields
|
||||
|
||||
from apps.alerts.models import MaintainableObject
|
||||
from apps.alerts.tasks import disable_maintenance
|
||||
from apps.slack.utils import post_message_to_channel
|
||||
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector, delete_slack_connector
|
||||
|
|
@ -75,6 +73,9 @@ class OrganizationManager(models.Manager):
|
|||
return OrganizationQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
|
||||
|
||||
|
||||
# TODO: in a subsequent PR, remove the inheritance from MaintainableObject (plus generate the database migration file)
|
||||
# this will remove the maintenance related columns that're no longer used on the organization object
|
||||
# class Organization(models.Model):
|
||||
class Organization(MaintainableObject):
|
||||
auth_tokens: "RelatedManager['ApiAuthToken']"
|
||||
mobile_app_auth_tokens: "RelatedManager['MobileAppAuthToken']"
|
||||
|
|
@ -255,39 +256,6 @@ class Organization(MaintainableObject):
|
|||
token_model = apps.get_model("auth_token", "PluginAuthToken")
|
||||
token_model.objects.filter(organization=self).delete()
|
||||
|
||||
"""
|
||||
Following methods: start_disable_maintenance_task, force_disable_maintenance, get_organization, get_verbal serve for
|
||||
MaintainableObject.
|
||||
"""
|
||||
|
||||
def start_disable_maintenance_task(self, countdown):
|
||||
maintenance_uuid = disable_maintenance.apply_async(
|
||||
args=(),
|
||||
kwargs={
|
||||
"organization_id": self.pk,
|
||||
},
|
||||
countdown=countdown,
|
||||
)
|
||||
return maintenance_uuid
|
||||
|
||||
def force_disable_maintenance(self, user):
|
||||
disable_maintenance(organization_id=self.pk, force=True, user_id=user.pk)
|
||||
|
||||
def get_organization(self):
|
||||
return self
|
||||
|
||||
def get_team(self):
|
||||
return None
|
||||
|
||||
def get_verbal(self):
|
||||
return self.org_title
|
||||
|
||||
def notify_about_maintenance_action(self, text, send_to_general_log_channel=True):
|
||||
# TODO: this method should be refactored.
|
||||
# It's binded to slack and sending maintenance notification only there.
|
||||
if send_to_general_log_channel:
|
||||
post_message_to_channel(self, self.general_log_channel_id, text)
|
||||
|
||||
"""
|
||||
Following methods:
|
||||
phone_calls_left, sms_left, emails_left
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ title = "Direct paging"
|
|||
slug = "direct_paging"
|
||||
short_description = None
|
||||
description = None
|
||||
is_displayed_on_web = False
|
||||
is_displayed_on_web = True
|
||||
is_featured = False
|
||||
is_able_to_autoresolve = False
|
||||
is_demo_alert_enabled = False
|
||||
|
|
|
|||
|
|
@ -408,6 +408,7 @@ CELERY_MAX_TASKS_PER_CHILD = 1
|
|||
CELERY_WORKER_SEND_TASK_EVENTS = True
|
||||
CELERY_TASK_SEND_SENT_EVENT = True
|
||||
|
||||
ESCALATION_AUDITOR_ENABLED = getenv_boolean("ESCALATION_AUDITOR_ENABLED", default=True)
|
||||
ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL = os.getenv(
|
||||
"ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_URL", None
|
||||
)
|
||||
|
|
@ -418,15 +419,6 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"schedule": 10 * 60,
|
||||
"args": (),
|
||||
},
|
||||
"check_escalations": {
|
||||
"task": "apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task",
|
||||
# the task should be executed a minute or two less than the integration's configured interval
|
||||
#
|
||||
# ex. if the integration is configured to expect a heartbeat every 15 minutes then this value should be set
|
||||
# to something like 13 * 60 (every 13 minutes)
|
||||
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
|
||||
"args": (),
|
||||
},
|
||||
"start_refresh_ical_final_schedules": {
|
||||
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules",
|
||||
"schedule": crontab(minute=15, hour=0),
|
||||
|
|
@ -498,6 +490,17 @@ CELERY_BEAT_SCHEDULE = {
|
|||
},
|
||||
}
|
||||
|
||||
if ESCALATION_AUDITOR_ENABLED:
|
||||
CELERY_BEAT_SCHEDULE["check_escalations"] = {
|
||||
"task": "apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task",
|
||||
# the task should be executed a minute or two less than the integration's configured interval
|
||||
#
|
||||
# ex. if the integration is configured to expect a heartbeat every 15 minutes then this value should be set
|
||||
# to something like 13 * 60 (every 13 minutes)
|
||||
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
|
||||
"args": (),
|
||||
}
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
SELF_IP = os.environ.get("SELF_IP")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ module.exports = {
|
|||
plugins: ['rulesdir', 'import'],
|
||||
settings: {
|
||||
'import/internal-regex':
|
||||
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils|^plugin',
|
||||
'^assets|^components|^containers|^icons|^img|^models|^network|^pages|^services|^state|^utils|^plugin',
|
||||
},
|
||||
rules: {
|
||||
eqeqeq: 'warn',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { test, expect, Page, Locator } from '../fixtures';
|
||||
import { verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated } from '../utils/alertGroup';
|
||||
import { EscalationStep, createEscalationChain } from '../utils/escalationChain';
|
||||
import { clickButton, generateRandomValue, selectDropdownValue } from '../utils/forms';
|
||||
import {
|
||||
assignEscalationChainToIntegration,
|
||||
createIntegration,
|
||||
filterIntegrationsTableAndGoToDetailPage,
|
||||
sendDemoAlert,
|
||||
} from '../utils/integrations';
|
||||
import { goToOnCallPage } from '../utils/navigation';
|
||||
|
||||
type MaintenanceModeType = 'Debug' | 'Maintenance';
|
||||
|
||||
test.describe('maintenance mode works', () => {
|
||||
test.slow(); // this test is doing a good amount of work, give it time
|
||||
|
||||
const MAINTENANCE_DURATION = '1 hour';
|
||||
const REMAINING_TIME_TEXT = '59m left';
|
||||
const REMAINING_TIME_TOOLTIP_TEST_ID = 'maintenance-mode-remaining-time-tooltip';
|
||||
|
||||
const createRoutedText = (escalationChainName: string): string =>
|
||||
`alert group assigned to route "default" with escalation chain "${escalationChainName}"`;
|
||||
|
||||
const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
|
||||
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
|
||||
await integrationSettingsPopupElement.click();
|
||||
return integrationSettingsPopupElement;
|
||||
};
|
||||
|
||||
const getRemainingTimeTooltip = (page: Page): Locator => page.getByTestId(REMAINING_TIME_TOOLTIP_TEST_ID);
|
||||
|
||||
const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise<void> => {
|
||||
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);
|
||||
/**
|
||||
* we need to click twice here, because adding the escalation chain route
|
||||
* doesn't unfocus out of the select element after selecting an option
|
||||
*/
|
||||
await integrationSettingsPopupElement.click();
|
||||
|
||||
// open the maintenance mode settings drawer + fill in the maintenance details
|
||||
await page.getByTestId('integration-start-maintenance').click();
|
||||
|
||||
// fill in the form
|
||||
const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer');
|
||||
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
startingLocator: maintenanceModeDrawer,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Choose mode',
|
||||
value: mode,
|
||||
optionExactMatch: false,
|
||||
});
|
||||
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
startingLocator: maintenanceModeDrawer,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Choose duration',
|
||||
value: MAINTENANCE_DURATION,
|
||||
optionExactMatch: false,
|
||||
});
|
||||
|
||||
await maintenanceModeDrawer.getByTestId('create-maintenance-button').click();
|
||||
|
||||
const maintenanceModeRemainingTimeTooltip = getRemainingTimeTooltip(page);
|
||||
await maintenanceModeRemainingTimeTooltip.waitFor({ state: 'visible' });
|
||||
|
||||
expect(await page.getByTestId(`${REMAINING_TIME_TOOLTIP_TEST_ID}-text`).innerText()).toContain(REMAINING_TIME_TEXT);
|
||||
};
|
||||
|
||||
const disableMaintenanceMode = async (page: Page, integrationName: string): Promise<void> => {
|
||||
await goToOnCallPage(page, 'integrations');
|
||||
|
||||
await filterIntegrationsTableAndGoToDetailPage(page, integrationName);
|
||||
await _openIntegrationSettingsPopup(page);
|
||||
|
||||
// click the stop maintenance button
|
||||
await page.getByTestId('integration-stop-maintenance').click();
|
||||
|
||||
// in the modal popup, confirm that we want to stop it
|
||||
await clickButton({
|
||||
page,
|
||||
buttonText: 'Stop',
|
||||
startingLocator: page.getByRole('dialog'),
|
||||
});
|
||||
|
||||
await getRemainingTimeTooltip(page).waitFor({ state: 'hidden' });
|
||||
};
|
||||
|
||||
const createIntegrationAndEscalationChainAndEnableMaintenanceMode = async (
|
||||
page: Page,
|
||||
userName: string,
|
||||
maintenanceModeType: MaintenanceModeType
|
||||
): Promise<{
|
||||
escalationChainName: string;
|
||||
integrationName: string;
|
||||
}> => {
|
||||
const escalationChainName = generateRandomValue();
|
||||
const integrationName = generateRandomValue();
|
||||
|
||||
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
|
||||
await createIntegration(page, integrationName);
|
||||
await assignEscalationChainToIntegration(page, escalationChainName);
|
||||
await enableMaintenanceMode(page, maintenanceModeType);
|
||||
|
||||
return { escalationChainName, integrationName };
|
||||
};
|
||||
|
||||
test('debug mode', async ({ adminRolePage: { page, userName } }) => {
|
||||
const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
|
||||
page,
|
||||
userName,
|
||||
'Debug'
|
||||
);
|
||||
await sendDemoAlert(page);
|
||||
await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
|
||||
page,
|
||||
integrationName,
|
||||
createRoutedText(escalationChainName)
|
||||
);
|
||||
|
||||
await disableMaintenanceMode(page, integrationName);
|
||||
});
|
||||
|
||||
test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => {
|
||||
const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
|
||||
page,
|
||||
userName,
|
||||
'Maintenance'
|
||||
);
|
||||
await sendDemoAlert(page);
|
||||
|
||||
// TODO: there seems to be a bug here where "maintenance" mode alert groups don't show up in the UI
|
||||
// await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
|
||||
// page,
|
||||
// integrationName,
|
||||
// createRoutedText(escalationChainName)
|
||||
// );
|
||||
|
||||
await disableMaintenanceMode(page, integrationName);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { Page, expect } from '@playwright/test';
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
import { selectDropdownValue, selectValuePickerValue } from './forms';
|
||||
import { goToOnCallPage } from './navigation';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const ALERT_GROUP_REGISTERED_TEXT = 'alert group registered';
|
||||
|
||||
// const sleep = async (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
const getIncidentTimelineList = async (page: Page): Promise<Locator> => {
|
||||
const incidentTimelineList = page.getByTestId('incident-timeline-list');
|
||||
await incidentTimelineList.waitFor({ state: 'visible' });
|
||||
return incidentTimelineList;
|
||||
};
|
||||
|
||||
/**
|
||||
* recursively refreshes the page waiting for the background celery workers to have done their job of
|
||||
|
|
@ -15,18 +20,28 @@ const incidentTimelineContainsStep = async (page: Page, triggeredStepText: strin
|
|||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (!page.getByTestId('incident-timeline-list').getByText(triggeredStepText)) {
|
||||
const incidentTimelineList = await getIncidentTimelineList(page);
|
||||
|
||||
if (!incidentTimelineList.getByText(triggeredStepText)) {
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
return incidentTimelineContainsStep(page, triggeredStepText, (retryNum += 1));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const verifyThatAlertGroupIsTriggered = async (
|
||||
/**
|
||||
* recursively refreshes the page waiting for the background celery workers to have done their job of
|
||||
* creating the alert group
|
||||
*/
|
||||
export const filterAlertGroupsTableByIntegrationAndGoToDetailPage = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
triggeredStepText: string
|
||||
retryNum = 0
|
||||
): Promise<void> => {
|
||||
if (retryNum > MAX_RETRIES) {
|
||||
throw new Error('we were not able to properly filter the alert groups table by integration');
|
||||
}
|
||||
|
||||
await goToOnCallPage(page, 'incidents');
|
||||
|
||||
// filter by integration
|
||||
|
|
@ -40,10 +55,48 @@ export const verifyThatAlertGroupIsTriggered = async (
|
|||
await selectValuePickerValue(page, integrationName, false);
|
||||
|
||||
/**
|
||||
* wait for the alert groups to be filtered then
|
||||
* click on the alert group and go to the individual alert group page
|
||||
* wait for the alert groups to be filtered then by this particular integration (toBeVisible assertion),
|
||||
* then click on the alert group and go to the individual alert group page
|
||||
*/
|
||||
await (await page.waitForSelector('table > tbody > tr > td:nth-child(4) a')).click();
|
||||
const firstTableRow = page.locator('table > tbody > tr:first-child');
|
||||
|
||||
try {
|
||||
/**
|
||||
* wait for up to 5 seconds for the alert groups to be filtered, if the first row does not correspond
|
||||
* to `integrationName` assume that the background workers have not created it yet and lets
|
||||
* recursively retry this function
|
||||
*/
|
||||
await firstTableRow.getByText(integrationName).waitFor({ state: 'visible', timeout: 5000 });
|
||||
await firstTableRow.locator('td:nth-child(4) a').click();
|
||||
} catch (err) {
|
||||
return filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName, (retryNum += 1));
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
routedText: string
|
||||
): Promise<void> => {
|
||||
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
|
||||
|
||||
/**
|
||||
* incidentTimelineContainsStep recursively reloads the alert group page until the engine
|
||||
* background workers have processed/escalated the alert group
|
||||
*/
|
||||
expect(await incidentTimelineContainsStep(page, ALERT_GROUP_REGISTERED_TEXT)).toBe(true);
|
||||
|
||||
const incidentTimelineList = await getIncidentTimelineList(page);
|
||||
expect(incidentTimelineList).toContainText(routedText);
|
||||
expect(incidentTimelineList).not.toContainText('triggered step');
|
||||
};
|
||||
|
||||
export const verifyThatAlertGroupIsTriggered = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
triggeredStepText: string
|
||||
): Promise<void> => {
|
||||
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
|
||||
|
||||
expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ type SelectDropdownValueArgs = {
|
|||
startingLocator?: Locator;
|
||||
// if true, when selecting the dropdown option, use an exact match, otherwise use a substring contains match
|
||||
optionExactMatch?: boolean;
|
||||
|
||||
// if true, will press enter in the select dropdown. Some dropdowns don't show a list of options
|
||||
// and instead the user must press enter to trigger the search
|
||||
pressEnterInsteadOfSelectingOption?: boolean;
|
||||
};
|
||||
|
||||
type ClickButtonArgs = {
|
||||
|
|
@ -87,9 +91,16 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel
|
|||
page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click();
|
||||
|
||||
export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<Locator> => {
|
||||
const { page, value, pressEnterInsteadOfSelectingOption } = args;
|
||||
|
||||
const selectElement = await openSelect(args);
|
||||
await selectElement.type(args.value);
|
||||
await chooseDropdownValue(args);
|
||||
await selectElement.type(value);
|
||||
|
||||
if (pressEnterInsteadOfSelectingOption) {
|
||||
await page.keyboard.press('Enter');
|
||||
} else {
|
||||
await chooseDropdownValue(args);
|
||||
}
|
||||
|
||||
return selectElement;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Page } from '@playwright/test';
|
||||
import { clickButton } from './forms';
|
||||
import { clickButton, selectDropdownValue } from './forms';
|
||||
import { goToOnCallPage } from './navigation';
|
||||
|
||||
const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]';
|
||||
|
|
@ -15,11 +15,7 @@ export const openCreateIntegrationModal = async (page: Page): Promise<void> => {
|
|||
await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR);
|
||||
};
|
||||
|
||||
export const createIntegrationAndSendDemoAlert = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
_escalationChainName: string
|
||||
): Promise<void> => {
|
||||
export const createIntegration = async (page: Page, integrationName: string): Promise<void> => {
|
||||
await openCreateIntegrationModal(page);
|
||||
|
||||
// create a webhook integration
|
||||
|
|
@ -27,25 +23,56 @@ export const createIntegrationAndSendDemoAlert = async (
|
|||
|
||||
// fill in the required inputs
|
||||
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName);
|
||||
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill("Here goes your integration description");
|
||||
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill(
|
||||
'Here goes your integration description'
|
||||
);
|
||||
|
||||
const grafanaUpdateBtn = page.getByTestId("update-integration-button");
|
||||
const grafanaUpdateBtn = page.getByTestId('update-integration-button');
|
||||
await grafanaUpdateBtn.click();
|
||||
|
||||
/*
|
||||
* TODO: This is slightly more complicated now, change this in next iteration */
|
||||
// const integrationSettingsElement = page.getByTestId('integration-settings');
|
||||
|
||||
// // assign the escalation chain to the integration
|
||||
// await selectDropdownValue({
|
||||
// page,
|
||||
// selectType: 'grafanaSelect',
|
||||
// placeholderText: 'Select Escalation Chain',
|
||||
// value: escalationChainName,
|
||||
// startingLocator: integrationSettingsElement,
|
||||
// });
|
||||
|
||||
// send demo alert
|
||||
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
|
||||
await clickButton({ page, buttonText: 'Send Alert', dataTestId: "submit-send-alert" })
|
||||
};
|
||||
|
||||
export const assignEscalationChainToIntegration = async (page: Page, escalationChainName: string): Promise<void> => {
|
||||
await page.getByTestId('integration-escalation-chain-not-selected').click();
|
||||
|
||||
// assign the escalation chain to the integration
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Select Escalation Chain',
|
||||
value: escalationChainName,
|
||||
startingLocator: page.getByTestId('integration-block-item'),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendDemoAlert = async (page: Page): Promise<void> => {
|
||||
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
|
||||
await clickButton({ page, buttonText: 'Send Alert', dataTestId: 'submit-send-alert' });
|
||||
await page.getByTestId('demo-alert-sent-notification').waitFor({ state: 'visible' });
|
||||
};
|
||||
|
||||
export const createIntegrationAndSendDemoAlert = async (
|
||||
page: Page,
|
||||
integrationName: string,
|
||||
escalationChainName: string
|
||||
): Promise<void> => {
|
||||
await createIntegration(page, integrationName);
|
||||
await assignEscalationChainToIntegration(page, escalationChainName);
|
||||
await sendDemoAlert(page);
|
||||
};
|
||||
|
||||
export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integrationName: string): Promise<void> => {
|
||||
// filter the integrations page by the integration in question, then go to its detail page
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Search or filter results...',
|
||||
value: integrationName,
|
||||
pressEnterInsteadOfSelectingOption: true,
|
||||
});
|
||||
|
||||
await (
|
||||
await page.waitForSelector(
|
||||
`div[data-testid="integrations-table"] table > tbody > tr > td:first-child a >> text=${integrationName}`
|
||||
)
|
||||
).click();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/
|
|||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './integration-tests',
|
||||
|
||||
/* Maximum time all the tests can run for. */
|
||||
globalTimeout: 20 * 60 * 1000, // 20 minutes
|
||||
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface IntegrationBlockItemProps {
|
|||
|
||||
const IntegrationBlockItem: React.FC<IntegrationBlockItemProps> = (props) => {
|
||||
return (
|
||||
<div className={cx('blockItem')}>
|
||||
<div className={cx('blockItem')} data-testid="integration-block-item">
|
||||
<div className={cx('blockItem__leftDelimitator')} />
|
||||
<div className={cx('blockItem__content')}>{props.children}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,19 +15,5 @@ export const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
|
|||
label: 'Description',
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
label: 'Assign to team',
|
||||
description:
|
||||
'Assigning to the teams allows you to filter Alert Groups and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
modelName: 'grafanaTeamStore',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,3 +6,23 @@
|
|||
background: var(--secondary-background);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.responders-list {
|
||||
list-style-type: none;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
background: var(--background-secondary);
|
||||
|
||||
& > li .hover-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > li:hover .hover-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
& > li {
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,35 @@
|
|||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Drawer,
|
||||
Field,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
IconButton,
|
||||
IconName,
|
||||
Label,
|
||||
LoadingPlaceholder,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
|
||||
import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
|
||||
import { Alert } from 'models/alertgroup/alertgroup.types';
|
||||
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
|
||||
import TeamName from 'containers/TeamName/TeamName';
|
||||
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Alert as AlertType } from 'models/alertgroup/alertgroup.types';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import IntegrationHelper from 'pages/integration/Integration.helper';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openWarningNotification } from 'utils';
|
||||
|
||||
import { manualAlertFormConfig } from './ManualAlertGroup.config';
|
||||
|
||||
|
|
@ -17,7 +37,8 @@ import styles from './ManualAlertGroup.module.css';
|
|||
|
||||
interface ManualAlertGroupProps {
|
||||
onHide: () => void;
|
||||
onCreate: (id: Alert['pk']) => void;
|
||||
onCreate: (id: AlertType['pk']) => void;
|
||||
alertReceiveChannelStore: AlertReceiveChannelStore;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
|
@ -26,13 +47,24 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
|
|||
const store = useStore();
|
||||
const [userResponders, setUserResponders] = useState([]);
|
||||
const [scheduleResponders, setScheduleResponders] = useState([]);
|
||||
const { onHide, onCreate } = props;
|
||||
const data = { team: store.userStore.currentUser?.current_team };
|
||||
const { onHide, onCreate, alertReceiveChannelStore } = props;
|
||||
|
||||
const [selectedTeamId, setSelectedTeam] = useState<GrafanaTeam['id']>();
|
||||
const [selectedTeamDirectPaging, setSelectedTeamDirectPaging] = useState<AlertReceiveChannel>();
|
||||
const [directPagingLoading, setdirectPagingLoading] = useState<boolean>();
|
||||
|
||||
const [chatOpsAvailableChannels, setChatopsAvailableChannels] = useState<any>();
|
||||
|
||||
const data = {};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
if (selectedTeamId === undefined) {
|
||||
openWarningNotification('Select team first');
|
||||
return;
|
||||
}
|
||||
store.directPagingStore
|
||||
.createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, data))
|
||||
.then(({ alert_group_id: id }: { alert_group_id: Alert['pk'] }) => {
|
||||
.createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, { team: selectedTeamId, ...data }))
|
||||
.then(({ alert_group_id: id }: { alert_group_id: AlertType['pk'] }) => {
|
||||
onCreate(id);
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -40,48 +72,174 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
|
|||
});
|
||||
};
|
||||
|
||||
const onUpdateSelectedTeam = async (selectedTeamId: GrafanaTeam['id']) => {
|
||||
setdirectPagingLoading(true);
|
||||
setSelectedTeamDirectPaging(null);
|
||||
setSelectedTeam(selectedTeamId);
|
||||
await alertReceiveChannelStore.updateItems({ team: selectedTeamId, integration: 'direct_paging' });
|
||||
const directPagingAlertReceiveChannel =
|
||||
alertReceiveChannelStore.getSearchResult() && alertReceiveChannelStore.getSearchResult()[0];
|
||||
if (directPagingAlertReceiveChannel) {
|
||||
setSelectedTeamDirectPaging(directPagingAlertReceiveChannel);
|
||||
await alertReceiveChannelStore.updateChannelFilters(directPagingAlertReceiveChannel.id);
|
||||
await store.slackChannelStore.updateItems();
|
||||
|
||||
// The code below is used to get the unique available chotops channels for all routes in integraion
|
||||
// This is the workaround for IntegrationHelper.getChatOpsChannels, it should be moved to the helper
|
||||
const filterIds = alertReceiveChannelStore.channelFilterIds[directPagingAlertReceiveChannel.id];
|
||||
let availableChannels = [];
|
||||
let channelKeys = new Set();
|
||||
filterIds.map((channelFilterId) => {
|
||||
IntegrationHelper.getChatOpsChannels(alertReceiveChannelStore.channelFilters[channelFilterId], store)
|
||||
.filter((channel) => channel)
|
||||
.map((channel) => {
|
||||
if (!channelKeys.has(channel.name + channel.icon)) {
|
||||
availableChannels.push(channel);
|
||||
channelKeys.add(channel.name + channel.icon);
|
||||
}
|
||||
});
|
||||
});
|
||||
setChatopsAvailableChannels(Array.from(availableChannels));
|
||||
}
|
||||
setdirectPagingLoading(false);
|
||||
};
|
||||
|
||||
const onUpdateEscalationVariants = useCallback(
|
||||
(value) => {
|
||||
setUserResponders(value.userResponders);
|
||||
|
||||
setScheduleResponders(value.scheduleResponders);
|
||||
},
|
||||
[userResponders, scheduleResponders]
|
||||
);
|
||||
|
||||
const DirectPagingIntegrationVariants = ({ selectedTeamId, selectedTeamDirectPaging, chatOpsAvailableChannels }) => {
|
||||
const escalationChainsExist = selectedTeamDirectPaging?.connected_escalations_chains_count === 0;
|
||||
|
||||
return (
|
||||
<VerticalGroup>
|
||||
{selectedTeamId &&
|
||||
(directPagingLoading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : selectedTeamDirectPaging ? (
|
||||
<VerticalGroup>
|
||||
<Label>Team will be notified according to the integration settings:</Label>
|
||||
<ul className={cx('responders-list')}>
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
{escalationChainsExist && (
|
||||
<Tooltip content="Integration doesn't have connected escalation policies">
|
||||
<Icon name="exclamation-triangle" style={{ color: 'var(--warning-text-color)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text>{selectedTeamDirectPaging.verbal_name}</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Team:</Text>
|
||||
<TeamName team={store.grafanaTeamStore.items[selectedTeamId]} />
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
{chatOpsAvailableChannels && (
|
||||
<>
|
||||
<Text type="secondary">ChatOps:</Text>{' '}
|
||||
{chatOpsAvailableChannels.map(
|
||||
(chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => (
|
||||
<div
|
||||
key={`${chatOpsChannel.name}-${chatOpsIndex}`}
|
||||
className={cx({
|
||||
'u-margin-right-xs': chatOpsIndex !== chatOpsAvailableChannels.length,
|
||||
})}
|
||||
>
|
||||
{chatOpsChannel.icon && <Icon name={chatOpsChannel.icon} className={cx('icon')} />}
|
||||
<Text type="primary">{chatOpsChannel.name || ''}</Text>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<PluginLink target="_blank" query={{ page: 'integrations', id: selectedTeamDirectPaging.id }}>
|
||||
<IconButton
|
||||
tooltip="Open integration in new tab"
|
||||
style={{ color: 'var(--always-gray)' }}
|
||||
name="external-link-alt"
|
||||
/>
|
||||
</PluginLink>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{(escalationChainsExist || !chatOpsAvailableChannels) && (
|
||||
<Alert severity="warning" title="Possible notification miss">
|
||||
<VerticalGroup>
|
||||
{escalationChainsExist && (
|
||||
<Text>
|
||||
Integration doesn't have connected escalation policies. Consider adding responders manually by
|
||||
user or by email
|
||||
</Text>
|
||||
)}
|
||||
{!chatOpsAvailableChannels && (
|
||||
<Text>Integration doesn't have connected ChatOps channels in messengers.</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
</Alert>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
) : (
|
||||
<Alert severity="warning" title={"This team doesn't have the the Direct Paging integration yet"}>
|
||||
<HorizontalGroup>
|
||||
<Text>
|
||||
Empty integration for this team will be created automatically. Consider selecting responders by
|
||||
schedule or user below
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</Alert>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const submitButtonDisabled = !(
|
||||
selectedTeamId &&
|
||||
(selectedTeamDirectPaging || userResponders.length || scheduleResponders.length)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer scrollableContent title="Create manual alert group" onClose={onHide} closeOnMaskClick={false}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<EscalationVariants
|
||||
value={{ userResponders, scheduleResponders }}
|
||||
onUpdateEscalationVariants={onUpdateEscalationVariants}
|
||||
/>
|
||||
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
|
||||
{store.teamStore.currentTeam.slack_team_identity && (
|
||||
<Block className={cx('info-block')}>
|
||||
<Icon name="info-circle" />{' '}
|
||||
<Text type="secondary">
|
||||
The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack
|
||||
channel.
|
||||
</Text>
|
||||
</Block>
|
||||
)}
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={manualAlertFormConfig.name}
|
||||
disabled={!userResponders.length && !scheduleResponders.length}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
</>
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title="Create manual alert group (Direct Paging)"
|
||||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
width="70%"
|
||||
>
|
||||
<VerticalGroup>
|
||||
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
|
||||
<Field label="Select team you want to notify">
|
||||
<GrafanaTeamSelect withoutModal onSelect={onUpdateSelectedTeam} />
|
||||
</Field>
|
||||
<DirectPagingIntegrationVariants
|
||||
selectedTeamId={selectedTeamId}
|
||||
selectedTeamDirectPaging={selectedTeamDirectPaging}
|
||||
chatOpsAvailableChannels={chatOpsAvailableChannels}
|
||||
/>
|
||||
<EscalationVariants
|
||||
value={{ userResponders, scheduleResponders }}
|
||||
onUpdateEscalationVariants={onUpdateEscalationVariants}
|
||||
variant={'secondary'}
|
||||
withLabels={true}
|
||||
/>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={manualAlertFormConfig.name} disabled={submitButtonDisabled}>
|
||||
Create
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
$score-primary: rgba(27, 133, 94, 0.15);
|
||||
$score-warning: rgba(245, 183, 61, 0.18);
|
||||
$score-danger: rgba(209, 14, 92, 0.15);
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -18,17 +14,17 @@ $score-danger: rgba(209, 14, 92, 0.15);
|
|||
padding: 4px 10px 3px 10px;
|
||||
|
||||
&--danger {
|
||||
background-color: $score-danger;
|
||||
background-color: var(--tag-background-danger);
|
||||
color: var(--tag-text-danger);
|
||||
border: 1px solid var(--tag-border-danger);
|
||||
}
|
||||
&--warning {
|
||||
background-color: $score-warning;
|
||||
background-color: var(--tag-background-warning);
|
||||
color: var(--tag-text-warning);
|
||||
border: 1px solid var(--tag-border-warning);
|
||||
}
|
||||
&--primary {
|
||||
background-color: $score-primary;
|
||||
background-color: var(--tag-background-success);
|
||||
color: var(--tag-text-success);
|
||||
border: 1px solid var(--tag-border-success);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ interface TooltipBadgeProps {
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
|
||||
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className } = props;
|
||||
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className, ...rest } =
|
||||
props;
|
||||
|
||||
const testId = rest['data-testid'];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
|
|
@ -48,10 +51,18 @@ const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
|
|||
className
|
||||
)}
|
||||
onMouseEnter={onHover}
|
||||
{...(testId ? { 'data-testid': testId } : {})}
|
||||
>
|
||||
<HorizontalGroup spacing="xs">
|
||||
{renderIcon()}
|
||||
{text && <Text className={cx('element__text', { [`element__text--${borderType}`]: true })}>{text}</Text>}
|
||||
{text && (
|
||||
<Text
|
||||
className={cx('element__text', { [`element__text--${borderType}`]: true })}
|
||||
{...(testId ? { 'data-testid': `${testId}-text` } : {})}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: calc(100vh - 600px);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.user {
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ const SortableHandleHoc = SortableHandle(DragHandle);
|
|||
const UserGroups = (props: UserGroupsProps) => {
|
||||
const { value, onChange, isMultipleGroups, renderUser, showError, disabled } = props;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>();
|
||||
|
||||
const handleAddUserGroup = useCallback(() => {
|
||||
onChange([...value, []]);
|
||||
}, [value]);
|
||||
|
|
@ -97,17 +95,6 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = rootRef.current.parentElement.parentElement.parentElement;
|
||||
const containerParent = container.parentElement;
|
||||
|
||||
containerParent.scroll({
|
||||
left: 0,
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
const renderItem = (item: Item, index: number) => (
|
||||
<li className={cx('user')}>
|
||||
{renderUser(item.data)}
|
||||
|
|
@ -123,7 +110,7 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={cx('root')} ref={rootRef}>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup>
|
||||
{!disabled && (
|
||||
<RemoteSelect
|
||||
|
|
@ -173,8 +160,20 @@ interface SortableListProps {
|
|||
|
||||
const SortableList = SortableContainer<SortableListProps>(
|
||||
({ items, handleAddGroup, isMultipleGroups, renderItem, allowCreate }) => {
|
||||
const listRef = useRef<HTMLUListElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const container = listRef.current;
|
||||
|
||||
container.scroll({
|
||||
left: 0,
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<ul className={cx('groups')}>
|
||||
<ul className={cx('groups')} ref={listRef}>
|
||||
{items.map((item, index) =>
|
||||
item.type === 'item' ? (
|
||||
<SortableItem key={item.key} index={index}>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { Select } from '@grafana/ui';
|
|||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
|
||||
import { Timezone, tzs } from 'models/timezone/timezone.types';
|
||||
import { getTzOffsetString, allTimezones } from 'models/timezone/timezone.helpers';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
|
||||
import styles from './UserTimezoneSelect.module.css';
|
||||
|
|
@ -111,7 +111,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
|
|||
|
||||
const handleCreateOption = useCallback(
|
||||
(value: string) => {
|
||||
const matched = tzs.find((tz) => tz.toLowerCase().includes(value.toLowerCase()));
|
||||
const matched = allTimezones.find((tz) => tz.toLowerCase().includes(value.toLowerCase()));
|
||||
if (matched) {
|
||||
const now = dayjs().tz(matched);
|
||||
const utcOffset = now.utcOffset();
|
||||
|
|
@ -150,7 +150,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
|
|||
allowCustomValue
|
||||
onCreateOption={handleCreateOption}
|
||||
formatCreateLabel={(input: string) => {
|
||||
const matched = tzs.find((tz) => tz.toLowerCase().includes(input.toLowerCase()));
|
||||
const matched = allTimezones.find((tz) => tz.toLowerCase().includes(input.toLowerCase()));
|
||||
const now = dayjs().tz(matched);
|
||||
if (matched) {
|
||||
return `Select ${getTzOffsetString(now)} (${matched})`;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
|
|||
renderMenuItems,
|
||||
forceIsOpen = false,
|
||||
focusOnOpen = true,
|
||||
...rest
|
||||
}) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false || forceIsOpen);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
|
@ -36,7 +37,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...rest}>
|
||||
{children({
|
||||
openMenu: (e) => {
|
||||
setIsMenuOpen(true);
|
||||
|
|
@ -56,6 +57,6 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
|
|||
focusOnOpen={focusOnOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ interface WorkingHoursProps {
|
|||
startMoment: dayjs.Dayjs;
|
||||
duration: number; // in seconds
|
||||
className: string;
|
||||
strong?: boolean;
|
||||
light?: boolean;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const WorkingHours: FC<WorkingHoursProps> = (props) => {
|
||||
const { timezone, workingHours = default_working_hours, startMoment, duration, className, strong = false } = props;
|
||||
const { timezone, workingHours = default_working_hours, startMoment, duration, className, light } = props;
|
||||
|
||||
const endMoment = startMoment.add(duration, 'seconds');
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ const WorkingHours: FC<WorkingHoursProps> = (props) => {
|
|||
<svg version="1.1" width="100%" height="28px" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<defs>
|
||||
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
|
||||
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
|
||||
<line x1="0" y="0" x2="0" y2="10" stroke="var(--working-hours-shades-color)" strokeWidth="10" />
|
||||
</pattern>
|
||||
<pattern id="stripes_strong" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
|
||||
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.2)" strokeWidth="10" />
|
||||
<pattern id="stripes_light" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
|
||||
<line x1="0" y="0" x2="0" y2="10" stroke="var(--working-hours-shades-color-light)" strokeWidth="10" />
|
||||
</pattern>
|
||||
</defs>
|
||||
{nonWorkingMoments &&
|
||||
|
|
@ -56,7 +56,7 @@ const WorkingHours: FC<WorkingHoursProps> = (props) => {
|
|||
y={0}
|
||||
width={`${(diff * 100) / duration}%`}
|
||||
height="100%"
|
||||
fill={`${strong ? 'url(#stripes_strong)' : 'url(#stripes)'}`}
|
||||
fill={light ? 'url(#stripes_light)' : 'url(#stripes)'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const CreateAlertReceiveChannelContainer = observer((props: CreateAlertReceiveCh
|
|||
label="Assign to team"
|
||||
description="OnCall teams allow you to organize integrations so you can filter and set up access. "
|
||||
>
|
||||
<GrafanaTeamSelect withoutModal onSelect={setSelectedTeam} />
|
||||
<GrafanaTeamSelect withoutModal onSelect={setSelectedTeam} defaultValue={user.current_team} />
|
||||
</Field>
|
||||
</div>
|
||||
<hr />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface EscalationVariantsProps {
|
|||
variant?: 'secondary' | 'primary';
|
||||
hideSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
withLabels?: boolean;
|
||||
}
|
||||
|
||||
const EscalationVariants = observer(
|
||||
|
|
@ -35,6 +36,7 @@ const EscalationVariants = observer(
|
|||
variant = 'primary',
|
||||
hideSelected = false,
|
||||
disabled,
|
||||
withLabels = false,
|
||||
}: EscalationVariantsProps) => {
|
||||
const [showEscalationVariants, setShowEscalationVariants] = useState(false);
|
||||
|
||||
|
|
@ -103,7 +105,7 @@ const EscalationVariants = observer(
|
|||
<div className={cx('body')}>
|
||||
{!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
|
||||
<>
|
||||
<Label>Responders:</Label>
|
||||
<Label>Additional Responders will be notified immediately:</Label>
|
||||
<ul className={cx('responders-list')}>
|
||||
{value.userResponders.map((responder, index) => (
|
||||
<UserResponder
|
||||
|
|
@ -125,6 +127,7 @@ const EscalationVariants = observer(
|
|||
</>
|
||||
)}
|
||||
<div className={cx('assign-responders-button')}>
|
||||
{withLabels && <Label>Assign additional responders from other teams (by user or by schedule)</Label>}
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button
|
||||
icon="users-alt"
|
||||
|
|
@ -134,7 +137,7 @@ const EscalationVariants = observer(
|
|||
setShowEscalationVariants(true);
|
||||
}}
|
||||
>
|
||||
Add responders
|
||||
Invite additional responders
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
|
|
@ -230,11 +233,11 @@ const UserResponder = ({ important, data, onImportantChange, handleDelete }) =>
|
|||
}}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<Text type="secondary">notification chain</Text>
|
||||
<Text type="secondary">notification policies</Text>
|
||||
</HorizontalGroup>
|
||||
) : (
|
||||
<HorizontalGroup>
|
||||
<Tooltip content="User doesn't have configured notification chains">
|
||||
<Tooltip content="User doesn't have configured notification policies">
|
||||
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
|
|
@ -18,15 +18,16 @@ interface GrafanaTeamSelectProps {
|
|||
onSelect: (id: GrafanaTeam['id']) => void;
|
||||
onHide?: () => void;
|
||||
withoutModal?: boolean;
|
||||
defaultValue?: GrafanaTeam['id'];
|
||||
}
|
||||
|
||||
const GrafanaTeamSelect = observer(({ onSelect, onHide, withoutModal }: GrafanaTeamSelectProps) => {
|
||||
const GrafanaTeamSelect = observer(({ onSelect, onHide, withoutModal, defaultValue }: GrafanaTeamSelectProps) => {
|
||||
const store = useStore();
|
||||
|
||||
const { userStore, grafanaTeamStore } = store;
|
||||
const user = userStore.currentUser;
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(user.current_team);
|
||||
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(defaultValue);
|
||||
|
||||
const grafanaTeams = grafanaTeamStore.getSearchResult();
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,9 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
|
|||
<div className={cx('icon-exclamation')}>
|
||||
<Icon name="exclamation-triangle" />
|
||||
</div>
|
||||
<Text type="primary">Not selected</Text>
|
||||
<Text type="primary" data-testid="integration-escalation-chain-not-selected">
|
||||
Not selected
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,8 +62,12 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
|
|||
.then((response) => {
|
||||
history.push(`${PLUGIN_ROOT}/integrations/${response.id}`);
|
||||
})
|
||||
.catch(() => {
|
||||
openErrorNotification('Something went wrong, please try again later.');
|
||||
.catch((err) => {
|
||||
if (err.response?.data?.length > 0) {
|
||||
openErrorNotification(err.response.data);
|
||||
} else {
|
||||
openErrorNotification('Something went wrong, please try again later.');
|
||||
}
|
||||
})
|
||||
: alertReceiveChannelStore.update(id, data)
|
||||
).then(() => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { SelectableValue } from '@grafana/data';
|
|||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { MaintenanceMode } from 'models/maintenance/maintenance.types';
|
||||
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
|
||||
export const form: { name: string; fields: FormItem[] } = {
|
||||
name: 'Maintenance',
|
||||
|
|
@ -31,6 +31,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
validation: { required: true },
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
placeholder: 'Choose mode',
|
||||
options: [
|
||||
{
|
||||
value: MaintenanceMode.Debug,
|
||||
|
|
@ -50,6 +51,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
type: FormItemType.Select,
|
||||
validation: { required: true },
|
||||
extra: {
|
||||
placeholder: 'Choose duration',
|
||||
options: [
|
||||
{
|
||||
value: 3600,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { observer } from 'mobx-react';
|
|||
import GForm from 'components/GForm/GForm';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openNotification, showApiError } from 'utils';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
|
@ -21,7 +20,6 @@ const cx = cn.bind(styles);
|
|||
|
||||
interface MaintenanceFormProps {
|
||||
initialData: {
|
||||
type?: MaintenanceType;
|
||||
alert_receive_channel_id?: AlertReceiveChannel['id'];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
|
@ -35,23 +33,22 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
|
||||
const store = useStore();
|
||||
|
||||
const { maintenanceStore } = store;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const handleSubmit = useCallback((data) => {
|
||||
maintenanceStore
|
||||
.startMaintenanceMode(
|
||||
MaintenanceType.alert_receive_channel,
|
||||
const handleSubmit = useCallback(async (data) => {
|
||||
try {
|
||||
await alertReceiveChannelStore.startMaintenanceMode(
|
||||
initialData.alert_receive_channel_id,
|
||||
data.mode,
|
||||
data.duration,
|
||||
data.alert_receive_channel_id
|
||||
)
|
||||
.then(() => {
|
||||
onHide();
|
||||
onUpdate();
|
||||
data.duration
|
||||
);
|
||||
|
||||
openNotification('Maintenance has been started');
|
||||
})
|
||||
.catch(showApiError);
|
||||
onHide();
|
||||
onUpdate();
|
||||
openNotification('Maintenance has been started');
|
||||
} catch (err) {
|
||||
showApiError(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (initialData.disabled) {
|
||||
|
|
@ -65,7 +62,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
|
||||
return (
|
||||
<Drawer width="640px" scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('content')} data-testid="maintenance-mode-drawer">
|
||||
<VerticalGroup>
|
||||
Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may
|
||||
trigger false alarms.
|
||||
|
|
@ -75,7 +72,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
Cancel
|
||||
</Button>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button form={form.name} type="submit">
|
||||
<Button form={form.name} type="submit" data-testid="create-maintenance-button">
|
||||
Start
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.root > * {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.root > *:not(:last-child) {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
|
||||
.root .search {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import React, { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RawTimeRange } from '@grafana/data';
|
||||
import { HorizontalGroup, Input, TimeRangeInput } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
|
||||
import styles from './OrganizationLogFilters.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface OrganizationLogFiltersProps {
|
||||
value: any;
|
||||
onChange: (filters: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OrganizationLogFilters = observer((props: OrganizationLogFiltersProps) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const [createAtRaw, setCreateAtRaw] = useState<RawTimeRange>();
|
||||
|
||||
const onSearchTermChangeCallback = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const filters = {
|
||||
...value,
|
||||
search: e.currentTarget.value,
|
||||
};
|
||||
|
||||
onChange(filters);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const getChangeHandler = (field: string) => {
|
||||
return (newValue: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
[field]: newValue,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const handleChangeCreatedAt = useCallback(
|
||||
(filter) => {
|
||||
onChange({
|
||||
...value,
|
||||
created_at: filter.from._isValid && filter.to._isValid ? [filter.from, filter.to] : undefined,
|
||||
});
|
||||
|
||||
setCreateAtRaw(filter.raw);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const createdAtValue = useMemo(() => {
|
||||
if (value['created_at']) {
|
||||
return { from: value['created_at'][0].toDate(), to: value['created_at'][1].toDate(), raw: createAtRaw };
|
||||
}
|
||||
return { from: undefined, to: undefined, raw: undefined };
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<HorizontalGroup wrap>
|
||||
<Input
|
||||
className={cx('search')}
|
||||
placeholder="Search..."
|
||||
value={value['search']}
|
||||
onChange={onSearchTermChangeCallback}
|
||||
/>
|
||||
<TimeRangeInput value={createdAtValue} onChange={handleChangeCreatedAt} hideTimeZone clearable />
|
||||
<RemoteSelect
|
||||
allowClear
|
||||
isMulti
|
||||
showSearch={false}
|
||||
className={cx('select')}
|
||||
value={value['labels']}
|
||||
onChange={getChangeHandler('labels')}
|
||||
href={'/organization_logs/label_options/'}
|
||||
fieldToShow="display_name"
|
||||
placeholder="Select labels..."
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default OrganizationLogFilters;
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
.body {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow: scroll;
|
||||
margin: 15px -15px;
|
||||
padding: 15px 0;
|
||||
border-top: var(--border-medium);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
|
|||
return (
|
||||
<div className={cx('root')}>
|
||||
<WorkingHours
|
||||
strong
|
||||
light
|
||||
startMoment={currentMoment.startOf('day')}
|
||||
duration={24 * 60 * 60}
|
||||
timezone={userStore.currentUser.timezone}
|
||||
|
|
|
|||
1
grafana-plugin/src/declare/index.d.ts
vendored
1
grafana-plugin/src/declare/index.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
declare module 'slack-markdown';
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<svg width="15px" height="15px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.6 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9963 1.71183C13.2187 0.932253 12.188 0.456443 11.0903 0.370349C9.99262 0.284256 8.90033 0.593556 8.01074 1.24238C7.07745 0.548204 5.91581 0.233431 4.75973 0.36145C3.60365 0.489468 2.53901 1.05077 1.78021 1.93232C1.02141 2.81387 0.624803 3.95018 0.670266 5.11244C0.715728 6.2747 1.19988 7.37656 2.02522 8.19615L6.58038 12.7586C6.96182 13.134 7.47556 13.3444 8.01074 13.3444C8.54593 13.3444 9.05966 13.134 9.44111 12.7586L13.9963 8.19615C14.8527 7.33446 15.3334 6.1689 15.3334 4.95399C15.3334 3.73908 14.8527 2.57352 13.9963 1.71183Z" fill="#6CCF8E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 666 B |
|
|
@ -1,24 +0,0 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 490.4 490.4"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M222.5,453.7c6.1,6.1,14.3,9.5,22.9,9.5c8.5,0,16.9-3.5,22.9-9.5L448,274c27.3-27.3,42.3-63.6,42.4-102.1
|
||||
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
|
||||
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
|
||||
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
|
||||
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
|
||||
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9863 1.68881C13.2081 0.910402 12.1764 0.437137 11.0788 0.355084C9.98115 0.273032 8.89053 0.587642 8.00525 1.2417C7.07266 0.548043 5.91188 0.233506 4.75667 0.361429C3.60145 0.489352 2.53761 1.05023 1.77937 1.93112C1.02114 2.81201 0.624834 3.94748 0.670263 5.10887C0.715692 6.27026 1.19948 7.3713 2.02421 8.19027L7.48484 13.6582C7.55298 13.7269 7.63405 13.7815 7.72337 13.8187C7.81269 13.8559 7.90849 13.875 8.00525 13.875C8.10202 13.875 8.19782 13.8559 8.28714 13.8187C8.37646 13.7815 8.45752 13.7269 8.52566 13.6582L13.9863 8.19027C14.4134 7.76348 14.7522 7.25672 14.9833 6.69894C15.2144 6.14117 15.3334 5.54331 15.3334 4.93954C15.3334 4.33577 15.2144 3.73792 14.9833 3.18014C14.7522 2.62237 14.4134 2.1156 13.9863 1.68881ZM12.9528 7.14945L8.00525 12.097L3.0577 7.14945C2.48995 6.59497 2.15463 5.84511 2.11989 5.05227C2.08516 4.25944 2.35361 3.48313 2.87069 2.88111C3.38777 2.2791 4.11467 1.89656 4.90367 1.81124C5.69266 1.72592 6.48454 1.94422 7.11836 2.42178L5.86498 5.35367C5.82142 5.44916 5.79888 5.5529 5.79888 5.65785C5.79888 5.76281 5.82142 5.86654 5.86498 5.96204C5.91153 6.05742 5.97824 6.14156 6.0605 6.20863C6.14276 6.27571 6.2386 6.32412 6.34141 6.35051L8.37174 6.86359L7.34558 8.98188C7.30305 9.06848 7.27803 9.16262 7.27197 9.2589C7.2659 9.35519 7.2789 9.45173 7.31023 9.54298C7.34155 9.63423 7.39058 9.7184 7.45451 9.79066C7.51844 9.86292 7.596 9.92184 7.68275 9.96406C7.78326 10.0127 7.89357 10.0378 8.00525 10.0374C8.14263 10.0376 8.27731 9.9993 8.39395 9.92672C8.51059 9.85415 8.60448 9.75026 8.66493 9.62689L10.1309 6.69501C10.1778 6.59656 10.2021 6.48888 10.2021 6.37983C10.2021 6.27078 10.1778 6.16311 10.1309 6.06465C10.0824 5.96637 10.0136 5.87957 9.92887 5.81004C9.84418 5.74052 9.74562 5.68988 9.63978 5.66152L7.56547 5.14111L8.62828 2.65634C9.2241 2.08092 10.0241 1.76577 10.8523 1.7802C11.6805 1.79463 12.469 2.13747 13.0444 2.7333C13.6198 3.32912 13.935 4.12912 13.9206 4.95731C13.9061 5.7855 13.5633 6.57404 12.9675 7.14945H12.9528Z" fill="#FF5286"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 330 KiB |
|
|
@ -1,19 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import qs from 'query-string';
|
||||
|
||||
import plugin from '../../package.json'; // eslint-disable-line
|
||||
|
||||
// Send version header to all requests
|
||||
axios.defaults.headers.common['X-OnCall-Plugin-Version'] = plugin?.version;
|
||||
|
||||
axios.interceptors.request.use(function (config) {
|
||||
// Do something before request is sent
|
||||
config.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'none' });
|
||||
};
|
||||
|
||||
return {
|
||||
...config,
|
||||
withCredentials: true,
|
||||
};
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
AlertReceiveChannel,
|
||||
AlertReceiveChannelOption,
|
||||
AlertReceiveChannelCounters,
|
||||
MaintenanceMode,
|
||||
} from './alert_receive_channel.types';
|
||||
|
||||
export class AlertReceiveChannelStore extends BaseStore {
|
||||
|
|
@ -456,4 +457,18 @@ export class AlertReceiveChannelStore extends BaseStore {
|
|||
|
||||
this.counters = counters;
|
||||
}
|
||||
|
||||
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
|
||||
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
mode,
|
||||
duration,
|
||||
},
|
||||
});
|
||||
|
||||
stopMaintenanceMode = (id: AlertReceiveChannel['id']) =>
|
||||
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { ReactElement } from 'react';
|
||||
|
||||
export interface CardData {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: number;
|
||||
icon: ReactElement;
|
||||
rate?: string;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
id?: any;
|
||||
lg?: string;
|
||||
xl?: string;
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { CurlerCheck, CurlerCheckStats, CurlerCheckPing } from './curler.types';
|
||||
|
||||
export class CurlerStore extends BaseStore {
|
||||
@observable.shallow
|
||||
items: { [uuid: string]: CurlerCheck } = {};
|
||||
|
||||
@observable.shallow
|
||||
searchResult: { [key: string]: Array<CurlerCheck['uuid']> } = {};
|
||||
|
||||
@observable.shallow
|
||||
stats: { [uuid: string]: CurlerCheckStats } = {};
|
||||
|
||||
@observable.shallow
|
||||
pings: {
|
||||
[uuid: string]: { [date: string]: CurlerCheckPing[] };
|
||||
} = {};
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
this.path = '/curler/checks/';
|
||||
}
|
||||
|
||||
@action
|
||||
async updateById(uuid: CurlerCheck['uuid']) {
|
||||
const response = await this.getById(uuid);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[uuid]: response,
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async updateItems(query = '', tzOffset: number) {
|
||||
const results = await makeRequest(`${this.path}`, {
|
||||
params: { search: query, offset: tzOffset },
|
||||
});
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
...results.reduce(
|
||||
(acc: { [key: string]: CurlerCheck }, item: CurlerCheck) => ({
|
||||
...acc,
|
||||
[item.uuid]: item,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
this.searchResult = {
|
||||
...this.searchResult,
|
||||
[query]: results.map((item: CurlerCheck) => item.uuid),
|
||||
};
|
||||
}
|
||||
|
||||
getSearchResult(query = '') {
|
||||
if (!this.searchResult[query]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.searchResult[query].map((checkId: CurlerCheck['uuid']) => this.items[checkId]);
|
||||
}
|
||||
|
||||
@action
|
||||
async updateStats(uuid: CurlerCheck['uuid'], tzOffset: number) {
|
||||
const response = await makeRequest(`${this.path}${uuid}/stats/`, {
|
||||
params: { offset: tzOffset },
|
||||
});
|
||||
|
||||
this.stats = {
|
||||
...this.stats,
|
||||
[uuid]: response,
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async updatePings(uuid: CurlerCheck['uuid'], date: string, tzOffset: number) {
|
||||
const response = await makeRequest(`${this.path}${uuid}/pings/`, {
|
||||
params: { created_at__date: date, offset: tzOffset },
|
||||
});
|
||||
|
||||
this.pings = {
|
||||
...this.pings,
|
||||
[uuid]: {
|
||||
...this.pings[uuid],
|
||||
[date]: response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async pause(uuid: CurlerCheck['uuid']) {
|
||||
return await makeRequest(`${this.path}${uuid}/pause/`, {
|
||||
method: 'PUT',
|
||||
}).catch(this.onApiError);
|
||||
}
|
||||
|
||||
@action
|
||||
async unpause(uuid: CurlerCheck['uuid']) {
|
||||
return await makeRequest(`${this.path}${uuid}/unpause/`, {
|
||||
method: 'PUT',
|
||||
}).catch(this.onApiError);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export interface CurlerCheck {
|
||||
uuid: string;
|
||||
created_at: string;
|
||||
alert_receive_channel: number;
|
||||
url: string;
|
||||
frequency: number;
|
||||
last_pings: CurlerCheckPing[];
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export interface CurlerCheckPing {
|
||||
id: number;
|
||||
url: string;
|
||||
created_at: string;
|
||||
http_status: number;
|
||||
latency_ms: number;
|
||||
successful: boolean;
|
||||
exception_reason: string | null;
|
||||
}
|
||||
|
||||
export interface CurlerCheckStats {
|
||||
max_latency: number | null;
|
||||
min_latency: number | null;
|
||||
uptime: number;
|
||||
last_downtime: string;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
export interface CurrentSubscriptionDTO {
|
||||
uuid: string;
|
||||
created_at: string;
|
||||
activation_expire_at: any;
|
||||
charges: string;
|
||||
stats: {
|
||||
result_credit: string;
|
||||
result_active_users_number: string;
|
||||
month: string;
|
||||
};
|
||||
subscription_plan: string;
|
||||
users_limit: string;
|
||||
current_stats: {
|
||||
// users_on_call: User['pk'][];
|
||||
// users_1_weeks_ago: User['pk'][];
|
||||
// users_2_weeks_ago: User['pk'][];
|
||||
// users_3_weeks_ago: User['pk'][];
|
||||
users_on_call: any[];
|
||||
users_1_weeks_ago: any[];
|
||||
users_2_weeks_ago: any[];
|
||||
users_3_weeks_ago: any[];
|
||||
active_users_count: number;
|
||||
estimate_credit: number;
|
||||
|
||||
is_billing_exists: boolean;
|
||||
active_plan: string;
|
||||
expires_at: string;
|
||||
paid_up_users: number;
|
||||
active_users: number;
|
||||
admins: number;
|
||||
users: number;
|
||||
|
||||
active_users_history: Array<{
|
||||
month: string;
|
||||
active_users_amount: number;
|
||||
}>;
|
||||
|
||||
billing_history: Array<{
|
||||
date: string;
|
||||
plan: number;
|
||||
paid_up_users_amount: number;
|
||||
charges: string;
|
||||
active_users: number;
|
||||
billing_statement: string;
|
||||
planned_next_period: boolean;
|
||||
}>;
|
||||
|
||||
usage_statistics: {
|
||||
users_on_call: string[];
|
||||
users_1_weeks_ago: string[];
|
||||
users_2_weeks_ago: string[];
|
||||
users_3_weeks_ago: string[];
|
||||
active_users_count: number;
|
||||
estimate_credit: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { Subscription } from './current_subscription.types';
|
||||
|
||||
export class CurrentSubscriptionStore extends BaseStore {
|
||||
@observable
|
||||
currentSubscription?: Subscription;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
this.path = '/current_subscription/';
|
||||
}
|
||||
|
||||
@action
|
||||
async updateCurrentSubscription() {
|
||||
this.currentSubscription = await makeRequest(this.path, {});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
interface UpdateLicenseOption {
|
||||
per_month: {
|
||||
price: number;
|
||||
product_id: number;
|
||||
};
|
||||
per_year: {
|
||||
price: number;
|
||||
product_id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
uuid: string;
|
||||
created_at: string;
|
||||
activation_expire_at: any;
|
||||
charges: string;
|
||||
stats: {
|
||||
result_credit: string;
|
||||
result_active_users_number: string;
|
||||
month: string;
|
||||
};
|
||||
subscription_plan: string;
|
||||
|
||||
active_plan: string;
|
||||
expires_at: string;
|
||||
|
||||
active_users: string[];
|
||||
stakeholders: string[];
|
||||
active_users_count: number;
|
||||
users_limit: number;
|
||||
stakeholders_count: number;
|
||||
stakeholders_limit: number;
|
||||
|
||||
show_stakeholders_in_violation_message: boolean;
|
||||
|
||||
update_licence_options: {
|
||||
business: UpdateLicenseOption;
|
||||
team: UpdateLicenseOption;
|
||||
};
|
||||
|
||||
current_team_primary_key: number;
|
||||
current_user_primary_key: number;
|
||||
|
||||
trial_days_left: number;
|
||||
|
||||
current_stats: {
|
||||
users_on_call: any[];
|
||||
users_1_weeks_ago: any[];
|
||||
users_2_weeks_ago: any[];
|
||||
users_3_weeks_ago: any[];
|
||||
active_users_count: number;
|
||||
estimate_credit: number;
|
||||
|
||||
is_billing_exists: boolean;
|
||||
active_plan: string;
|
||||
expires_at: string;
|
||||
paid_up_users: number;
|
||||
active_users: number;
|
||||
admins: number;
|
||||
users: number;
|
||||
|
||||
active_users_history: Array<{
|
||||
month: string;
|
||||
active_users_amount: number;
|
||||
}>;
|
||||
|
||||
billing_history: Array<{
|
||||
date: string;
|
||||
plan: number;
|
||||
paid_up_users_amount: number;
|
||||
charges: string;
|
||||
active_users: number;
|
||||
billing_statement: string;
|
||||
planned_next_period: boolean;
|
||||
}>;
|
||||
|
||||
usage_statistics: {
|
||||
users_on_call: string[];
|
||||
users_1_weeks_ago: string[];
|
||||
users_2_weeks_ago: string[];
|
||||
users_3_weeks_ago: string[];
|
||||
active_users_count: number;
|
||||
estimate_credit: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export interface IntegrationsListDTO {
|
||||
docs_url: string;
|
||||
logo_url: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export interface Leader {
|
||||
acknowledged_count: number;
|
||||
avatar: string;
|
||||
average_response_time: string;
|
||||
average_response_time_verbal: string;
|
||||
invited_count: number;
|
||||
resolved_count: number;
|
||||
user: string;
|
||||
username: string;
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { Maintenance, MaintenanceMode, MaintenanceType } from './maintenance.types';
|
||||
|
||||
export class MaintenanceStore extends BaseStore {
|
||||
@observable.shallow
|
||||
maintenances?: Maintenance[];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
this.path = '/maintenance/';
|
||||
}
|
||||
|
||||
@action
|
||||
async updateMaintenances() {
|
||||
this.maintenances = await this.getAll();
|
||||
}
|
||||
|
||||
@action
|
||||
async startMaintenanceMode(
|
||||
type: MaintenanceType,
|
||||
mode: MaintenanceMode,
|
||||
duration: number,
|
||||
alertReceiveChannelId?: AlertReceiveChannel['id']
|
||||
) {
|
||||
return await makeRequest(`/start_maintenance/`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type,
|
||||
mode,
|
||||
duration,
|
||||
alert_receive_channel_id: alertReceiveChannelId,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async stopMaintenanceMode(type: MaintenanceType, alertReceiveChannelId: AlertReceiveChannel['id']) {
|
||||
return await makeRequest(`/stop_maintenance/`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type,
|
||||
alert_receive_channel_id: alertReceiveChannelId,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
|
||||
export enum MaintenanceType {
|
||||
alert_receive_channel = 'alert_receive_channel',
|
||||
organization = 'organization',
|
||||
}
|
||||
|
||||
export enum MaintenanceMode {
|
||||
Debug,
|
||||
Maintenance,
|
||||
}
|
||||
|
||||
export interface Maintenance {
|
||||
alert_receive_channel_id: AlertReceiveChannel['id'];
|
||||
type: MaintenanceType;
|
||||
maintenance_mode: MaintenanceMode;
|
||||
maintenance_till_timestamp: number;
|
||||
started_at_timestamp: number;
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { OrganizationLog } from './organization_log.types';
|
||||
|
||||
export class OrganizationLogStore extends BaseStore {
|
||||
@observable.shallow
|
||||
items: { [id: string]: OrganizationLog } = {};
|
||||
|
||||
@observable.shallow
|
||||
searchResult?: {
|
||||
total: number;
|
||||
page: number;
|
||||
results: Array<OrganizationLog['id']>;
|
||||
};
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
this.path = '/organization_logs/';
|
||||
}
|
||||
|
||||
@action
|
||||
async updateItems(query = '', page: number, filters?: any) {
|
||||
const { results, count } = await makeRequest(`${this.path}`, {
|
||||
params: { search: query, page, ...filters },
|
||||
});
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
...results.reduce(
|
||||
(acc: { [key: string]: OrganizationLog }, item: OrganizationLog) => ({
|
||||
...acc,
|
||||
[item.id]: item,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
this.searchResult = {
|
||||
total: count,
|
||||
page,
|
||||
results: results.map((item: OrganizationLog) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
getSearchResult() {
|
||||
if (!this.searchResult) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...this.searchResult,
|
||||
results: this.searchResult.results.map((id: OrganizationLog['id']) => this.items[id]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { User } from 'models/user/user.types';
|
||||
|
||||
export interface OrganizationLog {
|
||||
id: string;
|
||||
author: Partial<User>;
|
||||
type: number;
|
||||
created_at: string;
|
||||
description: string;
|
||||
labels: string[];
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import dayjs from 'dayjs';
|
||||
|
||||
const tzs = [
|
||||
export const allTimezones = [
|
||||
'Africa/Abidjan',
|
||||
'Africa/Accra',
|
||||
'Africa/Addis_Ababa',
|
||||
|
|
@ -595,10 +595,6 @@ const tzs = [
|
|||
'Zulu',
|
||||
];
|
||||
|
||||
export const getRandomTimezone = () => {
|
||||
return tzs[Math.floor(Math.random() * tzs.length)];
|
||||
};
|
||||
|
||||
export const getTzOffsetString = (moment: dayjs.Dayjs) => {
|
||||
const userOffset = moment.utcOffset();
|
||||
const userOffsetHours = userOffset / 60;
|
||||
|
|
|
|||
|
|
@ -1,596 +1,3 @@
|
|||
export const tzs: string[] = [
|
||||
'Africa/Abidjan',
|
||||
'Africa/Accra',
|
||||
'Africa/Addis_Ababa',
|
||||
'Africa/Algiers',
|
||||
'Africa/Asmara',
|
||||
'Africa/Asmera',
|
||||
'Africa/Bamako',
|
||||
'Africa/Bangui',
|
||||
'Africa/Banjul',
|
||||
'Africa/Bissau',
|
||||
'Africa/Blantyre',
|
||||
'Africa/Brazzaville',
|
||||
'Africa/Bujumbura',
|
||||
'Africa/Cairo',
|
||||
'Africa/Casablanca',
|
||||
'Africa/Ceuta',
|
||||
'Africa/Conakry',
|
||||
'Africa/Dakar',
|
||||
'Africa/Dar_es_Salaam',
|
||||
'Africa/Djibouti',
|
||||
'Africa/Douala',
|
||||
'Africa/El_Aaiun',
|
||||
'Africa/Freetown',
|
||||
'Africa/Gaborone',
|
||||
'Africa/Harare',
|
||||
'Africa/Johannesburg',
|
||||
'Africa/Juba',
|
||||
'Africa/Kampala',
|
||||
'Africa/Khartoum',
|
||||
'Africa/Kigali',
|
||||
'Africa/Kinshasa',
|
||||
'Africa/Lagos',
|
||||
'Africa/Libreville',
|
||||
'Africa/Lome',
|
||||
'Africa/Luanda',
|
||||
'Africa/Lubumbashi',
|
||||
'Africa/Lusaka',
|
||||
'Africa/Malabo',
|
||||
'Africa/Maputo',
|
||||
'Africa/Maseru',
|
||||
'Africa/Mbabane',
|
||||
'Africa/Mogadishu',
|
||||
'Africa/Monrovia',
|
||||
'Africa/Nairobi',
|
||||
'Africa/Ndjamena',
|
||||
'Africa/Niamey',
|
||||
'Africa/Nouakchott',
|
||||
'Africa/Ouagadougou',
|
||||
'Africa/Porto-Novo',
|
||||
'Africa/Sao_Tome',
|
||||
'Africa/Timbuktu',
|
||||
'Africa/Tripoli',
|
||||
'Africa/Tunis',
|
||||
'Africa/Windhoek',
|
||||
'America/Adak',
|
||||
'America/Anchorage',
|
||||
'America/Anguilla',
|
||||
'America/Antigua',
|
||||
'America/Araguaina',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Argentina/Catamarca',
|
||||
'America/Argentina/ComodRivadavia',
|
||||
'America/Argentina/Cordoba',
|
||||
'America/Argentina/Jujuy',
|
||||
'America/Argentina/La_Rioja',
|
||||
'America/Argentina/Mendoza',
|
||||
'America/Argentina/Rio_Gallegos',
|
||||
'America/Argentina/Salta',
|
||||
'America/Argentina/San_Juan',
|
||||
'America/Argentina/San_Luis',
|
||||
'America/Argentina/Tucuman',
|
||||
'America/Argentina/Ushuaia',
|
||||
'America/Aruba',
|
||||
'America/Asuncion',
|
||||
'America/Atikokan',
|
||||
'America/Atka',
|
||||
'America/Bahia',
|
||||
'America/Bahia_Banderas',
|
||||
'America/Barbados',
|
||||
'America/Belem',
|
||||
'America/Belize',
|
||||
'America/Blanc-Sablon',
|
||||
'America/Boa_Vista',
|
||||
'America/Bogota',
|
||||
'America/Boise',
|
||||
'America/Buenos_Aires',
|
||||
'America/Cambridge_Bay',
|
||||
'America/Campo_Grande',
|
||||
'America/Cancun',
|
||||
'America/Caracas',
|
||||
'America/Catamarca',
|
||||
'America/Cayenne',
|
||||
'America/Cayman',
|
||||
'America/Chicago',
|
||||
'America/Chihuahua',
|
||||
'America/Coral_Harbour',
|
||||
'America/Cordoba',
|
||||
'America/Costa_Rica',
|
||||
'America/Creston',
|
||||
'America/Cuiaba',
|
||||
'America/Curacao',
|
||||
'America/Danmarkshavn',
|
||||
'America/Dawson',
|
||||
'America/Dawson_Creek',
|
||||
'America/Denver',
|
||||
'America/Detroit',
|
||||
'America/Dominica',
|
||||
'America/Edmonton',
|
||||
'America/Eirunepe',
|
||||
'America/El_Salvador',
|
||||
'America/Ensenada',
|
||||
'America/Fort_Nelson',
|
||||
'America/Fort_Wayne',
|
||||
'America/Fortaleza',
|
||||
'America/Glace_Bay',
|
||||
'America/Godthab',
|
||||
'America/Goose_Bay',
|
||||
'America/Grand_Turk',
|
||||
'America/Grenada',
|
||||
'America/Guadeloupe',
|
||||
'America/Guatemala',
|
||||
'America/Guayaquil',
|
||||
'America/Guyana',
|
||||
'America/Halifax',
|
||||
'America/Havana',
|
||||
'America/Hermosillo',
|
||||
'America/Indiana/Indianapolis',
|
||||
'America/Indiana/Knox',
|
||||
'America/Indiana/Marengo',
|
||||
'America/Indiana/Petersburg',
|
||||
'America/Indiana/Tell_City',
|
||||
'America/Indiana/Vevay',
|
||||
'America/Indiana/Vincennes',
|
||||
'America/Indiana/Winamac',
|
||||
'America/Indianapolis',
|
||||
'America/Inuvik',
|
||||
'America/Iqaluit',
|
||||
'America/Jamaica',
|
||||
'America/Jujuy',
|
||||
'America/Juneau',
|
||||
'America/Kentucky/Louisville',
|
||||
'America/Kentucky/Monticello',
|
||||
'America/Knox_IN',
|
||||
'America/Kralendijk',
|
||||
'America/La_Paz',
|
||||
'America/Lima',
|
||||
'America/Los_Angeles',
|
||||
'America/Louisville',
|
||||
'America/Lower_Princes',
|
||||
'America/Maceio',
|
||||
'America/Managua',
|
||||
'America/Manaus',
|
||||
'America/Marigot',
|
||||
'America/Martinique',
|
||||
'America/Matamoros',
|
||||
'America/Mazatlan',
|
||||
'America/Mendoza',
|
||||
'America/Menominee',
|
||||
'America/Merida',
|
||||
'America/Metlakatla',
|
||||
'America/Mexico_City',
|
||||
'America/Miquelon',
|
||||
'America/Moncton',
|
||||
'America/Monterrey',
|
||||
'America/Montevideo',
|
||||
'America/Montreal',
|
||||
'America/Montserrat',
|
||||
'America/Nassau',
|
||||
'America/New_York',
|
||||
'America/Nipigon',
|
||||
'America/Nome',
|
||||
'America/Noronha',
|
||||
'America/North_Dakota/Beulah',
|
||||
'America/North_Dakota/Center',
|
||||
'America/North_Dakota/New_Salem',
|
||||
'America/Ojinaga',
|
||||
'America/Panama',
|
||||
'America/Pangnirtung',
|
||||
'America/Paramaribo',
|
||||
'America/Phoenix',
|
||||
'America/Port-au-Prince',
|
||||
'America/Port_of_Spain',
|
||||
'America/Porto_Acre',
|
||||
'America/Porto_Velho',
|
||||
'America/Puerto_Rico',
|
||||
'America/Punta_Arenas',
|
||||
'America/Rainy_River',
|
||||
'America/Rankin_Inlet',
|
||||
'America/Recife',
|
||||
'America/Regina',
|
||||
'America/Resolute',
|
||||
'America/Rio_Branco',
|
||||
'America/Rosario',
|
||||
'America/Santa_Isabel',
|
||||
'America/Santarem',
|
||||
'America/Santiago',
|
||||
'America/Santo_Domingo',
|
||||
'America/Sao_Paulo',
|
||||
'America/Scoresbysund',
|
||||
'America/Shiprock',
|
||||
'America/Sitka',
|
||||
'America/St_Barthelemy',
|
||||
'America/St_Johns',
|
||||
'America/St_Kitts',
|
||||
'America/St_Lucia',
|
||||
'America/St_Thomas',
|
||||
'America/St_Vincent',
|
||||
'America/Swift_Current',
|
||||
'America/Tegucigalpa',
|
||||
'America/Thule',
|
||||
'America/Thunder_Bay',
|
||||
'America/Tijuana',
|
||||
'America/Toronto',
|
||||
'America/Tortola',
|
||||
'America/Vancouver',
|
||||
'America/Virgin',
|
||||
'America/Whitehorse',
|
||||
'America/Winnipeg',
|
||||
'America/Yakutat',
|
||||
'America/Yellowknife',
|
||||
'Antarctica/Casey',
|
||||
'Antarctica/Davis',
|
||||
'Antarctica/DumontDUrville',
|
||||
'Antarctica/Macquarie',
|
||||
'Antarctica/Mawson',
|
||||
'Antarctica/McMurdo',
|
||||
'Antarctica/Palmer',
|
||||
'Antarctica/Rothera',
|
||||
'Antarctica/South_Pole',
|
||||
'Antarctica/Syowa',
|
||||
'Antarctica/Troll',
|
||||
'Antarctica/Vostok',
|
||||
'Arctic/Longyearbyen',
|
||||
'Asia/Aden',
|
||||
'Asia/Almaty',
|
||||
'Asia/Amman',
|
||||
'Asia/Anadyr',
|
||||
'Asia/Aqtau',
|
||||
'Asia/Aqtobe',
|
||||
'Asia/Ashgabat',
|
||||
'Asia/Ashkhabad',
|
||||
'Asia/Atyrau',
|
||||
'Asia/Baghdad',
|
||||
'Asia/Bahrain',
|
||||
'Asia/Baku',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Barnaul',
|
||||
'Asia/Beirut',
|
||||
'Asia/Bishkek',
|
||||
'Asia/Brunei',
|
||||
'Asia/Calcutta',
|
||||
'Asia/Chita',
|
||||
'Asia/Choibalsan',
|
||||
'Asia/Chongqing',
|
||||
'Asia/Chungking',
|
||||
'Asia/Colombo',
|
||||
'Asia/Dacca',
|
||||
'Asia/Damascus',
|
||||
'Asia/Dhaka',
|
||||
'Asia/Dili',
|
||||
'Asia/Dubai',
|
||||
'Asia/Dushanbe',
|
||||
'Asia/Famagusta',
|
||||
'Asia/Gaza',
|
||||
'Asia/Harbin',
|
||||
'Asia/Hebron',
|
||||
'Asia/Ho_Chi_Minh',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Hovd',
|
||||
'Asia/Irkutsk',
|
||||
'Asia/Istanbul',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Jayapura',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Kabul',
|
||||
'Asia/Kamchatka',
|
||||
'Asia/Karachi',
|
||||
'Asia/Kashgar',
|
||||
'Asia/Kathmandu',
|
||||
'Asia/Katmandu',
|
||||
'Asia/Khandyga',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Krasnoyarsk',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Kuching',
|
||||
'Asia/Kuwait',
|
||||
'Asia/Macao',
|
||||
'Asia/Macau',
|
||||
'Asia/Magadan',
|
||||
'Asia/Makassar',
|
||||
'Asia/Manila',
|
||||
'Asia/Muscat',
|
||||
'Asia/Nicosia',
|
||||
'Asia/Novokuznetsk',
|
||||
'Asia/Novosibirsk',
|
||||
'Asia/Omsk',
|
||||
'Asia/Oral',
|
||||
'Asia/Phnom_Penh',
|
||||
'Asia/Pontianak',
|
||||
'Asia/Pyongyang',
|
||||
'Asia/Qatar',
|
||||
'Asia/Qyzylorda',
|
||||
'Asia/Rangoon',
|
||||
'Asia/Riyadh',
|
||||
'Asia/Saigon',
|
||||
'Asia/Sakhalin',
|
||||
'Asia/Samarkand',
|
||||
'Asia/Seoul',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Singapore',
|
||||
'Asia/Srednekolymsk',
|
||||
'Asia/Taipei',
|
||||
'Asia/Tashkent',
|
||||
'Asia/Tbilisi',
|
||||
'Asia/Tehran',
|
||||
'Asia/Tel_Aviv',
|
||||
'Asia/Thimbu',
|
||||
'Asia/Thimphu',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Tomsk',
|
||||
'Asia/Ujung_Pandang',
|
||||
'Asia/Ulaanbaatar',
|
||||
'Asia/Ulan_Bator',
|
||||
'Asia/Urumqi',
|
||||
'Asia/Ust-Nera',
|
||||
'Asia/Vientiane',
|
||||
'Asia/Vladivostok',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Yangon',
|
||||
'Asia/Yekaterinburg',
|
||||
'Asia/Yerevan',
|
||||
'Atlantic/Azores',
|
||||
'Atlantic/Bermuda',
|
||||
'Atlantic/Canary',
|
||||
'Atlantic/Cape_Verde',
|
||||
'Atlantic/Faeroe',
|
||||
'Atlantic/Faroe',
|
||||
'Atlantic/Jan_Mayen',
|
||||
'Atlantic/Madeira',
|
||||
'Atlantic/Reykjavik',
|
||||
'Atlantic/South_Georgia',
|
||||
'Atlantic/St_Helena',
|
||||
'Atlantic/Stanley',
|
||||
'Australia/ACT',
|
||||
'Australia/Adelaide',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Broken_Hill',
|
||||
'Australia/Canberra',
|
||||
'Australia/Currie',
|
||||
'Australia/Darwin',
|
||||
'Australia/Eucla',
|
||||
'Australia/Hobart',
|
||||
'Australia/LHI',
|
||||
'Australia/Lindeman',
|
||||
'Australia/Lord_Howe',
|
||||
'Australia/Melbourne',
|
||||
'Australia/NSW',
|
||||
'Australia/North',
|
||||
'Australia/Perth',
|
||||
'Australia/Queensland',
|
||||
'Australia/South',
|
||||
'Australia/Sydney',
|
||||
'Australia/Tasmania',
|
||||
'Australia/Victoria',
|
||||
'Australia/West',
|
||||
'Australia/Yancowinna',
|
||||
'Brazil/Acre',
|
||||
'Brazil/DeNoronha',
|
||||
'Brazil/East',
|
||||
'Brazil/West',
|
||||
'CET',
|
||||
'CST6CDT',
|
||||
'Canada/Atlantic',
|
||||
'Canada/Central',
|
||||
'Canada/Eastern',
|
||||
'Canada/Mountain',
|
||||
'Canada/Newfoundland',
|
||||
'Canada/Pacific',
|
||||
'Canada/Saskatchewan',
|
||||
'Canada/Yukon',
|
||||
'Chile/Continental',
|
||||
'Chile/EasterIsland',
|
||||
'Cuba',
|
||||
'EET',
|
||||
'EST',
|
||||
'EST5EDT',
|
||||
'Egypt',
|
||||
'Eire',
|
||||
'Etc/GMT',
|
||||
'Etc/GMT+0',
|
||||
'Etc/GMT+1',
|
||||
'Etc/GMT+10',
|
||||
'Etc/GMT+11',
|
||||
'Etc/GMT+12',
|
||||
'Etc/GMT+2',
|
||||
'Etc/GMT+3',
|
||||
'Etc/GMT+4',
|
||||
'Etc/GMT+5',
|
||||
'Etc/GMT+6',
|
||||
'Etc/GMT+7',
|
||||
'Etc/GMT+8',
|
||||
'Etc/GMT+9',
|
||||
'Etc/GMT-0',
|
||||
'Etc/GMT-1',
|
||||
'Etc/GMT-10',
|
||||
'Etc/GMT-11',
|
||||
'Etc/GMT-12',
|
||||
'Etc/GMT-13',
|
||||
'Etc/GMT-14',
|
||||
'Etc/GMT-2',
|
||||
'Etc/GMT-3',
|
||||
'Etc/GMT-4',
|
||||
'Etc/GMT-5',
|
||||
'Etc/GMT-6',
|
||||
'Etc/GMT-7',
|
||||
'Etc/GMT-8',
|
||||
'Etc/GMT-9',
|
||||
'Etc/GMT0',
|
||||
'Etc/Greenwich',
|
||||
'Etc/UCT',
|
||||
'Etc/UTC',
|
||||
'Etc/Universal',
|
||||
'Etc/Zulu',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Andorra',
|
||||
'Europe/Astrakhan',
|
||||
'Europe/Athens',
|
||||
'Europe/Belfast',
|
||||
'Europe/Belgrade',
|
||||
'Europe/Berlin',
|
||||
'Europe/Bratislava',
|
||||
'Europe/Brussels',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Budapest',
|
||||
'Europe/Busingen',
|
||||
'Europe/Chisinau',
|
||||
'Europe/Copenhagen',
|
||||
'Europe/Dublin',
|
||||
'Europe/Gibraltar',
|
||||
'Europe/Guernsey',
|
||||
'Europe/Helsinki',
|
||||
'Europe/Isle_of_Man',
|
||||
'Europe/Istanbul',
|
||||
'Europe/Jersey',
|
||||
'Europe/Kaliningrad',
|
||||
'Europe/Kiev',
|
||||
'Europe/Kirov',
|
||||
'Europe/Lisbon',
|
||||
'Europe/Ljubljana',
|
||||
'Europe/London',
|
||||
'Europe/Luxembourg',
|
||||
'Europe/Madrid',
|
||||
'Europe/Malta',
|
||||
'Europe/Mariehamn',
|
||||
'Europe/Minsk',
|
||||
'Europe/Monaco',
|
||||
'Europe/Moscow',
|
||||
'Europe/Nicosia',
|
||||
'Europe/Oslo',
|
||||
'Europe/Paris',
|
||||
'Europe/Podgorica',
|
||||
'Europe/Prague',
|
||||
'Europe/Riga',
|
||||
'Europe/Rome',
|
||||
'Europe/Samara',
|
||||
'Europe/San_Marino',
|
||||
'Europe/Sarajevo',
|
||||
'Europe/Saratov',
|
||||
'Europe/Simferopol',
|
||||
'Europe/Skopje',
|
||||
'Europe/Sofia',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Tallinn',
|
||||
'Europe/Tirane',
|
||||
'Europe/Tiraspol',
|
||||
'Europe/Ulyanovsk',
|
||||
'Europe/Uzhgorod',
|
||||
'Europe/Vaduz',
|
||||
'Europe/Vatican',
|
||||
'Europe/Vienna',
|
||||
'Europe/Vilnius',
|
||||
'Europe/Volgograd',
|
||||
'Europe/Warsaw',
|
||||
'Europe/Zagreb',
|
||||
'Europe/Zaporozhye',
|
||||
'Europe/Zurich',
|
||||
'GB',
|
||||
'GB-Eire',
|
||||
'GMT',
|
||||
'GMT+0',
|
||||
'GMT-0',
|
||||
'GMT0',
|
||||
'Greenwich',
|
||||
'HST',
|
||||
'Hongkong',
|
||||
'Iceland',
|
||||
'Indian/Antananarivo',
|
||||
'Indian/Chagos',
|
||||
'Indian/Christmas',
|
||||
'Indian/Cocos',
|
||||
'Indian/Comoro',
|
||||
'Indian/Kerguelen',
|
||||
'Indian/Mahe',
|
||||
'Indian/Maldives',
|
||||
'Indian/Mauritius',
|
||||
'Indian/Mayotte',
|
||||
'Indian/Reunion',
|
||||
'Iran',
|
||||
'Israel',
|
||||
'Jamaica',
|
||||
'Japan',
|
||||
'Kwajalein',
|
||||
'Libya',
|
||||
'MET',
|
||||
'MST',
|
||||
'MST7MDT',
|
||||
'Mexico/BajaNorte',
|
||||
'Mexico/BajaSur',
|
||||
'Mexico/General',
|
||||
'NZ',
|
||||
'NZ-CHAT',
|
||||
'Navajo',
|
||||
'PRC',
|
||||
'PST8PDT',
|
||||
'Pacific/Apia',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Bougainville',
|
||||
'Pacific/Chatham',
|
||||
'Pacific/Chuuk',
|
||||
'Pacific/Easter',
|
||||
'Pacific/Efate',
|
||||
'Pacific/Enderbury',
|
||||
'Pacific/Fakaofo',
|
||||
'Pacific/Fiji',
|
||||
'Pacific/Funafuti',
|
||||
'Pacific/Galapagos',
|
||||
'Pacific/Gambier',
|
||||
'Pacific/Guadalcanal',
|
||||
'Pacific/Guam',
|
||||
'Pacific/Honolulu',
|
||||
'Pacific/Johnston',
|
||||
'Pacific/Kiritimati',
|
||||
'Pacific/Kosrae',
|
||||
'Pacific/Kwajalein',
|
||||
'Pacific/Majuro',
|
||||
'Pacific/Marquesas',
|
||||
'Pacific/Midway',
|
||||
'Pacific/Nauru',
|
||||
'Pacific/Niue',
|
||||
'Pacific/Norfolk',
|
||||
'Pacific/Noumea',
|
||||
'Pacific/Pago_Pago',
|
||||
'Pacific/Palau',
|
||||
'Pacific/Pitcairn',
|
||||
'Pacific/Pohnpei',
|
||||
'Pacific/Ponape',
|
||||
'Pacific/Port_Moresby',
|
||||
'Pacific/Rarotonga',
|
||||
'Pacific/Saipan',
|
||||
'Pacific/Samoa',
|
||||
'Pacific/Tahiti',
|
||||
'Pacific/Tarawa',
|
||||
'Pacific/Tongatapu',
|
||||
'Pacific/Truk',
|
||||
'Pacific/Wake',
|
||||
'Pacific/Wallis',
|
||||
'Pacific/Yap',
|
||||
'Poland',
|
||||
'Portugal',
|
||||
'ROC',
|
||||
'ROK',
|
||||
'Singapore',
|
||||
'Turkey',
|
||||
'UCT',
|
||||
'US/Alaska',
|
||||
'US/Aleutian',
|
||||
'US/Arizona',
|
||||
'US/Central',
|
||||
'US/East-Indiana',
|
||||
'US/Eastern',
|
||||
'US/Hawaii',
|
||||
'US/Indiana-Starke',
|
||||
'US/Michigan',
|
||||
'US/Mountain',
|
||||
'US/Pacific',
|
||||
'US/Pacific-New',
|
||||
'US/Samoa',
|
||||
'UTC',
|
||||
'Universal',
|
||||
'W-SU',
|
||||
'WET',
|
||||
'Zulu',
|
||||
];
|
||||
import { allTimezones } from './timezone.helpers';
|
||||
|
||||
export type Timezone = (typeof tzs)[number];
|
||||
export type Timezone = (typeof allTimezones)[number];
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { action, observable } from 'mobx';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
import { Webinar } from './webinar.types';
|
||||
|
||||
export class WebinarStore extends BaseStore {
|
||||
@observable.shallow
|
||||
searchResult?: { [key: string]: Array<Webinar['id']> };
|
||||
|
||||
@observable.shallow
|
||||
items?: { [id: string]: Webinar };
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
this.path = '/webinars/';
|
||||
}
|
||||
|
||||
@action
|
||||
async subscribe(id: Webinar['id']) {
|
||||
return await makeRequest(`/webinars/${id}/subscribe/`, {
|
||||
method: 'POST',
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
async updateItems(query = '') {
|
||||
const result = await this.getAll();
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
...result.reduce(
|
||||
(acc: { [key: number]: Webinar }, item: Webinar) => ({
|
||||
...acc,
|
||||
[item.id]: item,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
this.searchResult = {
|
||||
...(this.searchResult || {}),
|
||||
[query]: result.map((item: Webinar) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
getSearchResult(query = '') {
|
||||
if (!this.searchResult || !this.items) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.searchResult[query].map((scheduleId: Webinar['id']) => this.items?.[scheduleId]);
|
||||
}
|
||||
|
||||
getFutureWebinarsCount(): number {
|
||||
const items = this.getSearchResult();
|
||||
if (!items) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return items.filter((webinar?: Webinar) => moment(webinar?.datetime).isAfter() && !webinar?.subscribed).length;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { UserDTO } from 'models/user';
|
||||
|
||||
export interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
additional_emails: string[];
|
||||
datetime: string;
|
||||
description: string;
|
||||
image: string;
|
||||
link: string;
|
||||
registered_users: Array<UserDTO['pk']>;
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
const { showAddAlertGroupForm } = this.state;
|
||||
const {
|
||||
store,
|
||||
store: { alertGroupStore },
|
||||
store: { alertGroupStore, alertReceiveChannelStore },
|
||||
} = this.props;
|
||||
|
||||
if (!alertGroupStore.irmPlan && !store.isOpenSource()) {
|
||||
|
|
@ -126,7 +126,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
<Text.Title level={3}>Alert Groups</Text.Title>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
|
||||
New alert group
|
||||
New manual alert group
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
|
|
@ -142,6 +142,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
onCreate={(id: Alert['pk']) => {
|
||||
history.push(`${PLUGIN_ROOT}/alert-groups/${id}`);
|
||||
}}
|
||||
alertReceiveChannelStore={alertReceiveChannelStore}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -144,13 +144,6 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
path: getPath('cloud'),
|
||||
action: UserActions.OtherSettingsWrite,
|
||||
},
|
||||
{
|
||||
icon: 'gf-logs',
|
||||
id: 'organization-logs',
|
||||
text: 'Org Logs',
|
||||
hideFromTabs: true,
|
||||
path: getPath('organization-logs'),
|
||||
},
|
||||
{
|
||||
icon: 'cog',
|
||||
id: 'test',
|
||||
|
|
@ -188,7 +181,6 @@ export const ROUTES = {
|
|||
outgoing_webhooks_2: ['outgoing_webhooks_2', 'outgoing_webhooks_2/:id', 'outgoing_webhooks_2/:action/:id'],
|
||||
maintenance: ['maintenance'],
|
||||
settings: ['settings'],
|
||||
'organization-logs': ['organization-logs'],
|
||||
'chat-ops': ['chat-ops'],
|
||||
'live-settings': ['live-settings'],
|
||||
cloud: ['cloud'],
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import {
|
|||
} from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { AlertTemplatesDTO } from 'models/alert_templates';
|
||||
import { ChannelFilter } from 'models/channel_filter';
|
||||
import { MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
import { INTEGRATION_TEMPLATES_LIST } from 'pages/integration/Integration.config';
|
||||
import IntegrationHelper from 'pages/integration/Integration.helper';
|
||||
import styles from 'pages/integration/Integration.module.scss';
|
||||
|
|
@ -327,7 +326,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
|
|||
Autoresolve:
|
||||
</Text>
|
||||
<Text type="primary">
|
||||
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')}
|
||||
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || 'disabled')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
|
@ -606,7 +605,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
|
|||
|
||||
const DemoNotification: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="demo-alert-sent-notification">
|
||||
Demo alert was generated. Find it on the
|
||||
<PluginLink query={{ page: 'alert-groups' }}> "Alert Groups" </PluginLink>
|
||||
page and make sure it didn't freak out your colleagues 😉
|
||||
|
|
@ -727,7 +726,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
alertReceiveChannel,
|
||||
changeIsTemplateSettingsOpen,
|
||||
}) => {
|
||||
const { maintenanceStore, alertReceiveChannelStore, heartbeatStore } = useStore();
|
||||
const { alertReceiveChannelStore, heartbeatStore } = useStore();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
|
|
@ -815,6 +814,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
</WithPermissionControlTooltip>
|
||||
|
||||
<WithContextMenu
|
||||
data-testid="integration-settings-context-menu"
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('integration__actionsList')} id="integration-menu-options">
|
||||
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
|
||||
|
|
@ -831,7 +831,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
|
||||
{!alertReceiveChannel.maintenance_till && (
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<div className={cx('integration__actionItem')} onClick={openStartMaintenance}>
|
||||
<div
|
||||
className={cx('integration__actionItem')}
|
||||
onClick={openStartMaintenance}
|
||||
data-testid="integration-start-maintenance"
|
||||
>
|
||||
<Text type="primary">Start Maintenance</Text>
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -862,6 +866,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
),
|
||||
});
|
||||
}}
|
||||
data-testid="integration-stop-maintenance"
|
||||
>
|
||||
<Text type="primary">Stop Maintenance</Text>
|
||||
</div>
|
||||
|
|
@ -941,14 +946,13 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
|
||||
}
|
||||
|
||||
function onStopMaintenance() {
|
||||
async function onStopMaintenance() {
|
||||
setConfirmModal(undefined);
|
||||
|
||||
maintenanceStore
|
||||
.stopMaintenanceMode(MaintenanceType.alert_receive_channel, id)
|
||||
.then(() => maintenanceStore.updateMaintenances())
|
||||
.then(() => openNotification('Maintenance has been stopped'))
|
||||
.then(() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id));
|
||||
await alertReceiveChannelStore.stopMaintenanceMode(id);
|
||||
|
||||
openNotification('Maintenance has been stopped');
|
||||
await alertReceiveChannelStore.updateItem(id);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -960,6 +964,17 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
|
|||
const item = alertReceiveChannelStore.items[id];
|
||||
const url = item?.integration_url || item?.inbound_email;
|
||||
|
||||
const howToConnectTagName = (integration: string) => {
|
||||
switch (integration) {
|
||||
case 'direct_paging':
|
||||
return 'Manual';
|
||||
case 'email':
|
||||
return 'Inbound Email';
|
||||
default:
|
||||
return 'HTTP Endpoint';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegrationBlock
|
||||
noContent={hasAlerts}
|
||||
|
|
@ -972,29 +987,50 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
|
|||
className={cx('how-to-connect__tag')}
|
||||
>
|
||||
<Text type="primary" size="small" className={cx('radius')}>
|
||||
{item?.inbound_email ? 'Inbound Email' : 'HTTP Endpoint'}
|
||||
{howToConnectTagName(item?.integration)}
|
||||
</Text>
|
||||
</Tag>
|
||||
{url && (
|
||||
<IntegrationInputField
|
||||
value={url}
|
||||
className={cx('integration__input-field')}
|
||||
showExternal={!!item?.integration_url}
|
||||
/>
|
||||
{item?.integration === 'direct_paging' ? (
|
||||
<>
|
||||
<Text type="secondary">Alert Groups raised manually via Web or ChatOps</Text>
|
||||
<a
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/manual"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('u-pull-right')}
|
||||
>
|
||||
<Text type="link" size="small">
|
||||
<HorizontalGroup>
|
||||
How it works
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{url && (
|
||||
<IntegrationInputField
|
||||
value={url}
|
||||
className={cx('integration__input-field')}
|
||||
showExternal={!!item?.integration_url}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('u-pull-right')}
|
||||
>
|
||||
<Text type="link" size="small">
|
||||
<HorizontalGroup>
|
||||
How to connect
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('u-pull-right')}
|
||||
>
|
||||
<Text type="link" size="small">
|
||||
<HorizontalGroup>
|
||||
How to connect
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
content={hasAlerts ? null : renderContent()}
|
||||
|
|
@ -1002,12 +1038,20 @@ const HowToConnectComponent: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id
|
|||
);
|
||||
|
||||
function renderContent() {
|
||||
const callToAction = () => {
|
||||
if (item?.integration === 'direct_paging') {
|
||||
return <Text type={'primary'}>try to raise a demo alert group via Web or Chatops</Text>;
|
||||
} else {
|
||||
return item.demo_alert_enabled && <Text type={'primary'}>; try to send a demo alert</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VerticalGroup justify={'flex-start'} spacing={'xs'}>
|
||||
{!hasAlerts && (
|
||||
<HorizontalGroup spacing={'xs'}>
|
||||
<Icon name="fa fa-spinner" size="md" className={cx('loadingPlaceholder')} />
|
||||
<Text type={'primary'}>No alerts yet; try to send a demo alert</Text>
|
||||
<Text type={'primary'}>No alerts yet;</Text> {callToAction()}
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
|
|
@ -1063,6 +1107,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
|
|||
|
||||
{alertReceiveChannel.maintenance_till && (
|
||||
<TooltipBadge
|
||||
data-testid="maintenance-mode-remaining-time-tooltip"
|
||||
borderType="primary"
|
||||
icon="pause"
|
||||
text={IntegrationHelper.getMaintenanceText(alertReceiveChannel.maintenance_till)}
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
/>
|
||||
<GTable
|
||||
emptyText={this.renderNotFound()}
|
||||
data-testid="integrations-table"
|
||||
rowKey="id"
|
||||
data={results}
|
||||
columns={columns}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,7 @@
|
|||
.title {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,220 +1,37 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, VerticalGroup } from '@grafana/ui';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import Text from 'components/Text/Text';
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import MaintenanceForm from 'containers/MaintenanceForm/MaintenanceForm';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
|
||||
import styles from './Maintenance.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface MaintenancePageProps extends PageProps, WithStoreProps {}
|
||||
|
||||
interface MaintenancePageState {
|
||||
maintenanceData?: {
|
||||
type?: MaintenanceType;
|
||||
alert_receive_channel_id?: AlertReceiveChannel['id'];
|
||||
disabled?: boolean;
|
||||
};
|
||||
}
|
||||
interface MaintenancePageProps {}
|
||||
|
||||
@observer
|
||||
class MaintenancePage extends React.Component<MaintenancePageProps, MaintenancePageState> {
|
||||
state: MaintenancePageState = {};
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
store: { alertReceiveChannelStore },
|
||||
} = this.props;
|
||||
|
||||
this.update().then(this.parseQueryParams);
|
||||
|
||||
alertReceiveChannelStore.updateItems().then(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MaintenancePageProps) {
|
||||
if (this.props.query.maintenance_type !== prevProps.query.maintenance_type) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
||||
parseQueryParams = () => {
|
||||
const { query } = this.props;
|
||||
|
||||
if ('maintenance_type' in query) {
|
||||
const preselectedMaintenanceType = query.maintenance_type as MaintenanceType;
|
||||
const preselectedAlertReceiveChannel = query.alert_receive_channel as AlertReceiveChannel['id'];
|
||||
|
||||
this.setState({
|
||||
maintenanceData: {
|
||||
type: preselectedMaintenanceType,
|
||||
alert_receive_channel_id: preselectedAlertReceiveChannel,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
const { maintenanceStore } = store;
|
||||
|
||||
return maintenanceStore.updateMaintenances();
|
||||
};
|
||||
|
||||
class MaintenancePage extends React.Component<MaintenancePageProps> {
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { maintenanceStore } = store;
|
||||
const { maintenanceData } = this.state;
|
||||
|
||||
const data = maintenanceStore?.maintenances;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
width: 300,
|
||||
title: 'Integration',
|
||||
render: this.renderTitle,
|
||||
key: 'Title',
|
||||
},
|
||||
{
|
||||
width: 200,
|
||||
title: 'Mode',
|
||||
render: this.renderMode,
|
||||
key: 'mode',
|
||||
},
|
||||
{
|
||||
title: 'Progress',
|
||||
render: this.renderDuration,
|
||||
key: 'progress',
|
||||
},
|
||||
{
|
||||
title: 'Time limit',
|
||||
render: this.renderTimer,
|
||||
key: 'timer',
|
||||
},
|
||||
{
|
||||
width: 100,
|
||||
key: 'action',
|
||||
render: this.renderActionButtons,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={data ? 'No maintenances found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<VerticalGroup>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Maintenance</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary" className={cx('title')}>
|
||||
Mute noisy sources or use for debugging and avoid bothering your colleagues.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ maintenanceData: {} });
|
||||
}}
|
||||
variant="primary"
|
||||
icon="plus"
|
||||
>
|
||||
New maintenance
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
{maintenanceData && (
|
||||
<MaintenanceForm
|
||||
initialData={maintenanceData}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ maintenanceData: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
severity="info"
|
||||
className={cx('info-box')}
|
||||
// @ts-ignore
|
||||
title={
|
||||
<>
|
||||
Maintenance mode is now controlled at the{' '}
|
||||
<PluginLink query={{ page: 'integrations' }}> Integration</PluginLink> level. This page will soon be
|
||||
removed.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderTitle = (maintenance: Maintenance) => {
|
||||
const { store } = this.props;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
const alertReceiveChannel = alertReceiveChannelStore.items
|
||||
? alertReceiveChannelStore.items[maintenance.alert_receive_channel_id]
|
||||
: undefined;
|
||||
|
||||
switch (maintenance.type) {
|
||||
case MaintenanceType.alert_receive_channel:
|
||||
return <Emoji text={getAlertReceiveChannelDisplayName(alertReceiveChannel)} />;
|
||||
|
||||
case MaintenanceType.organization:
|
||||
return `${store.teamStore.currentTeam?.name} Team`;
|
||||
}
|
||||
};
|
||||
|
||||
renderMode = (maintenance: Maintenance) => {
|
||||
return maintenance.maintenance_mode === MaintenanceMode.Debug ? 'Debug' : 'Maintenance';
|
||||
};
|
||||
|
||||
renderActionButtons = (maintenance: Maintenance) => {
|
||||
return (
|
||||
<div className={cx('buttons')}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<WithConfirm title="Are you sure to stop?" confirmText="Stop">
|
||||
<Button variant="destructive" fill="text" onClick={this.getStopMaintenanceHandler(maintenance)}>
|
||||
Stop
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderDuration = (maintenance: Maintenance) => {
|
||||
const started = moment(maintenance.started_at_timestamp * 1000);
|
||||
const ended = moment(maintenance.maintenance_till_timestamp * 1000);
|
||||
return `${started.format('MMM DD, YYYY HH:mm')} - ${ended.format('MMM DD, YYYY hh:mm')}`;
|
||||
};
|
||||
|
||||
renderTimer = (maintenance: Maintenance) => {
|
||||
return `ends ${moment(maintenance.maintenance_till_timestamp * 1000).fromNow()}`;
|
||||
};
|
||||
|
||||
getStopMaintenanceHandler = (maintenance: Maintenance) => {
|
||||
const { store } = this.props;
|
||||
const { maintenanceStore } = store;
|
||||
|
||||
return () => {
|
||||
maintenanceStore.stopMaintenanceMode(maintenance.type, maintenance.alert_receive_channel_id).then(this.update);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(MaintenancePage);
|
||||
export default MaintenancePage;
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.no-author {
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.root .no-background {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.root .short-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 400px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Tag, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment, { Moment } from 'moment-timezone';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import OrganizationLogFilters from 'containers/OrganizationLogFilters/OrganizationLogFilters';
|
||||
import logo from 'img/logo.svg';
|
||||
import { OrganizationLog } from 'models/organization_log/organization_log.types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import sanitize from 'utils/sanitize';
|
||||
|
||||
import styles from './OrganizationLog.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface OrganizationLogProps extends WithStoreProps, RouteComponentProps {}
|
||||
|
||||
interface OrganizationLogState {
|
||||
filters: { [key: string]: any };
|
||||
page: number;
|
||||
expandedLogsKeys: string[];
|
||||
}
|
||||
|
||||
const INITIAL_FILTERS = {};
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
@observer
|
||||
class OrganizationLogPage extends React.Component<OrganizationLogProps, OrganizationLogState> {
|
||||
state: OrganizationLogState = { filters: { ...INITIAL_FILTERS }, page: 1, expandedLogsKeys: [] };
|
||||
|
||||
componentDidMount() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { filters, page } = this.state;
|
||||
store.OrganizationLogStore.updateItems('', page, {
|
||||
...filters,
|
||||
created_at: filters.created_at
|
||||
? filters.created_at.map((m: Moment) => m.utc().format('YYYY-MM-DDTHH:mm:ss')).join('/')
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
debouncedRefresh = debounce(this.refresh, 500);
|
||||
|
||||
render() {
|
||||
const { filters, expandedLogsKeys } = this.state;
|
||||
const { store } = this.props;
|
||||
const { OrganizationLogStore } = store;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
width: '40%',
|
||||
title: 'Action',
|
||||
render: this.renderShortDescription,
|
||||
key: 'action',
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'User',
|
||||
render: this.renderUser,
|
||||
key: 'user',
|
||||
},
|
||||
{
|
||||
width: '30%',
|
||||
title: 'Labels',
|
||||
render: this.renderLabels,
|
||||
key: 'labels',
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
title: 'Time',
|
||||
render: this.renderCreatedAt,
|
||||
key: 'created_at',
|
||||
},
|
||||
];
|
||||
|
||||
const searchResult: any = OrganizationLogStore.getSearchResult() || {};
|
||||
|
||||
const { total, page, results } = searchResult;
|
||||
|
||||
const loading = !results;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<OrganizationLogFilters value={filters} onChange={this.handleChangeOrganizationLogFilters} />
|
||||
<GTable
|
||||
rowKey="id"
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title className={cx('users-title')} level={3}>
|
||||
Organization Logs
|
||||
</Text.Title>
|
||||
<Button onClick={this.refresh} icon={loading ? 'fa fa-spinner' : 'sync'}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
showHeader={true}
|
||||
data={results}
|
||||
loading={loading}
|
||||
emptyText={results ? 'No logs found' : 'Loading...'}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((total || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
rowClassName={cx('align-top')}
|
||||
expandable={{
|
||||
expandedRowRender: this.renderFullDescription,
|
||||
expandRowByClick: true,
|
||||
expandedRowKeys: expandedLogsKeys,
|
||||
onExpandedRowsChange: this.handleExpandedRowsChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleExpandedRowsChange = (expandedRows: string[]) => {
|
||||
this.setState({ expandedLogsKeys: expandedRows });
|
||||
};
|
||||
|
||||
handleChangePage = (page: number) => {
|
||||
this.setState({ page }, this.refresh);
|
||||
};
|
||||
|
||||
handleChangeOrganizationLogFilters = (filters: any) => {
|
||||
this.setState({ filters, page: 1 }, this.debouncedRefresh);
|
||||
};
|
||||
|
||||
renderShortDescription = (item: OrganizationLog) => {
|
||||
return <div className={cx('short-description')}>{item.description}</div>;
|
||||
};
|
||||
|
||||
renderFullDescription = (item: OrganizationLog) => {
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(item.description),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderUser = (item: OrganizationLog) => {
|
||||
if (!item.author) {
|
||||
return (
|
||||
<Tooltip content="System event">
|
||||
<Avatar size="large" className={cx('no-background')} src={logo} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginLink query={{ page: 'users', id: item.author.pk }}>
|
||||
<Tooltip placement="top" key={item.author.pk} content={item.author.username}>
|
||||
<span>
|
||||
<Avatar size="large" src={item.author.avatar} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</PluginLink>
|
||||
);
|
||||
};
|
||||
|
||||
renderLabels = (item: OrganizationLog) => {
|
||||
if (!item.labels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HorizontalGroup wrap>
|
||||
{item.labels.map((label) => (
|
||||
<Tag key={label} name={label} />
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
renderCreatedAt = (item: OrganizationLog) => {
|
||||
return moment(item.created_at).toString();
|
||||
};
|
||||
}
|
||||
|
||||
export default withMobXProviderContext(OrganizationLogPage);
|
||||
|
|
@ -2,7 +2,6 @@ import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
|
|||
import IncidentPage from 'pages/incident/Incident';
|
||||
import IncidentsPage from 'pages/incidents/Incidents';
|
||||
import MaintenancePage from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2';
|
||||
import SchedulePage from 'pages/schedule/Schedule';
|
||||
|
|
@ -73,10 +72,6 @@ export const routes: { [id: string]: NavRoute } = [
|
|||
component: LiveSettingsPage,
|
||||
id: 'live-settings',
|
||||
},
|
||||
{
|
||||
component: OrganizationLogPage,
|
||||
id: 'organization-logs',
|
||||
},
|
||||
{
|
||||
component: CloudPage,
|
||||
id: 'cloud',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import Incidents from 'pages/incidents/Incidents';
|
|||
import Integration from 'pages/integration/Integration';
|
||||
import Integrations from 'pages/integrations/Integrations';
|
||||
import Maintenance from 'pages/maintenance/Maintenance';
|
||||
import OrganizationLogPage from 'pages/organization-logs/OrganizationLog';
|
||||
import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2';
|
||||
import Schedule from 'pages/schedule/Schedule';
|
||||
|
|
@ -37,7 +36,6 @@ import ChatOps from 'pages/settings/tabs/ChatOps/ChatOps';
|
|||
import CloudPage from 'pages/settings/tabs/Cloud/CloudPage';
|
||||
import LiveSettings from 'pages/settings/tabs/LiveSettings/LiveSettingsPage';
|
||||
import Users from 'pages/users/Users';
|
||||
import 'interceptors';
|
||||
import { rootStore } from 'state';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { isUserActionAllowed } from 'utils/authorization';
|
||||
|
|
@ -171,14 +169,11 @@ export const Root = observer((props: AppRootProps) => {
|
|||
<OutgoingWebhooks2 query={query} />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('maintenance')} exact>
|
||||
<Maintenance query={query} />
|
||||
<Maintenance />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('settings')} exact>
|
||||
<SettingsPage />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('organization-logs')} exact>
|
||||
<OrganizationLogPage />
|
||||
</Route>
|
||||
<Route path={getRoutesForPage('chat-ops')} exact>
|
||||
<ChatOps query={query} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import Cookies from 'js-cookie';
|
||||
import qs from 'query-string';
|
||||
|
||||
const query = qs.parse(window.location.search);
|
||||
|
||||
if (query.version === 'nf') {
|
||||
Cookies.set('nf', '1', { expires: 31, path: '/' });
|
||||
}
|
||||
|
||||
export function getABTestVariant() {
|
||||
return Cookies.get('ab_test_variant');
|
||||
}
|
||||
|
||||
export function getIsNoFreeExperiment() {
|
||||
return Cookies.get('nf') === '1';
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export function pushConvertion(message: any) {
|
||||
if (window.dataLayer) {
|
||||
window.dataLayer.push(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import qs from 'query-string';
|
||||
|
||||
export function updateQueryParams(params: any, isReplace = false) {
|
||||
const query = qs.stringify(params);
|
||||
|
||||
if (isReplace) {
|
||||
window.history.replaceState(null, '', `${window.location.pathname}?${query}`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', `${window.location.pathname}?${query}`);
|
||||
}
|
||||
|
||||
export function mergeQueryParams(params: any) {
|
||||
const currentParams = qs.parse(window.location.search);
|
||||
const newParams = {
|
||||
...currentParams,
|
||||
...params,
|
||||
};
|
||||
|
||||
const query = qs.stringify(newParams);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
|
@ -18,8 +18,6 @@ import { FiltersStore } from 'models/filters/filters';
|
|||
import { GlobalSettingStore } from 'models/global_setting/global_setting';
|
||||
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
|
||||
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
|
||||
import { MaintenanceStore } from 'models/maintenance/maintenance';
|
||||
import { OrganizationLogStore } from 'models/organization_log/organization_log';
|
||||
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
|
||||
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
|
||||
import { ResolutionNotesStore } from 'models/resolution_note/resolution_note';
|
||||
|
|
@ -98,13 +96,11 @@ export class RootBaseStore {
|
|||
slackStore: SlackStore = new SlackStore(this);
|
||||
slackChannelStore: SlackChannelStore = new SlackChannelStore(this);
|
||||
heartbeatStore: HeartbeatStore = new HeartbeatStore(this);
|
||||
maintenanceStore: MaintenanceStore = new MaintenanceStore(this);
|
||||
scheduleStore: ScheduleStore = new ScheduleStore(this);
|
||||
userGroupStore: UserGroupStore = new UserGroupStore(this);
|
||||
alertGroupStore: AlertGroupStore = new AlertGroupStore(this);
|
||||
resolutionNotesStore: ResolutionNotesStore = new ResolutionNotesStore(this);
|
||||
apiTokenStore: ApiTokenStore = new ApiTokenStore(this);
|
||||
OrganizationLogStore: OrganizationLogStore = new OrganizationLogStore(this);
|
||||
globalSettingStore: GlobalSettingStore = new GlobalSettingStore(this);
|
||||
filtersStore: FiltersStore = new FiltersStore(this);
|
||||
// stores
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue