diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f699829e..72036a42 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,4 +4,4 @@ CHANGELOG.md /grafana-plugin @grafana/grafana-oncall-frontend -/docs @grafana/docs-oncall +/docs @grafana/docs-gops diff --git a/CHANGELOG.md b/CHANGELOG.md index be740c80..b945cff8 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index 27ca9e60..674f5d78 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -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`. diff --git a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py index b202ceb0..33917d12 100644 --- a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py +++ b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py @@ -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 diff --git a/engine/apps/alerts/migrations/0021_alter_alertgroup_started_at.py b/engine/apps/alerts/migrations/0021_alter_alertgroup_started_at.py new file mode 100644 index 00000000..2f05a63a --- /dev/null +++ b/engine/apps/alerts/migrations/0021_alter_alertgroup_started_at.py @@ -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), + ), + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index b49773e8..df4df514 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -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: diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 1cf41fec..e44f2527 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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 diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index 53a92ba9..d17f6f03 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -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 = [] diff --git a/engine/apps/alerts/tasks/check_escalation_finished.py b/engine/apps/alerts/tasks/check_escalation_finished.py index 993ec623..343696c9 100644 --- a/engine/apps/alerts/tasks/check_escalation_finished.py +++ b/engine/apps/alerts/tasks/check_escalation_finished.py @@ -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(): diff --git a/engine/apps/alerts/tasks/distribute_alert.py b/engine/apps/alerts/tasks/distribute_alert.py index 0d532772..e5b3e159 100644 --- a/engine/apps/alerts/tasks/distribute_alert.py +++ b/engine/apps/alerts/tasks/distribute_alert.py @@ -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, diff --git a/engine/apps/alerts/tasks/maintenance.py b/engine/apps/alerts/tasks/maintenance.py index 6c296248..b1118453 100644 --- a/engine/apps/alerts/tasks/maintenance.py +++ b/engine/apps/alerts/tasks/maintenance.py @@ -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}, - ) diff --git a/engine/apps/alerts/tasks/send_update_log_report_signal.py b/engine/apps/alerts/tasks/send_update_log_report_signal.py index 0705654c..771bf887 100644 --- a/engine/apps/alerts/tasks/send_update_log_report_signal.py +++ b/engine/apps/alerts/tasks/send_update_log_report_signal.py @@ -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"' ) diff --git a/engine/apps/alerts/tests/test_check_escalation_finished_task.py b/engine/apps/alerts/tests/test_check_escalation_finished_task.py index ad057102..ab0cfaaf 100644 --- a/engine/apps/alerts/tests/test_check_escalation_finished_task.py +++ b/engine/apps/alerts/tests/test_check_escalation_finished_task.py @@ -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() diff --git a/engine/apps/alerts/tests/test_maintenance.py b/engine/apps/alerts/tests/test_maintenance.py index 0db353c3..f3f6b57b 100644 --- a/engine/apps/alerts/tests/test_maintenance.py +++ b/engine/apps/alerts/tests/test_maintenance.py @@ -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 diff --git a/engine/apps/alerts/tests/test_paging.py b/engine/apps/alerts/tests/test_paging.py index 5d3c79b0..daa73a6a 100644 --- a/engine/apps/alerts/tests/test_paging.py +++ b/engine/apps/alerts/tests/test_paging.py @@ -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() diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 7e60e943..2cea5a96 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -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): diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index da345d62..67c8e34b 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -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): diff --git a/engine/apps/api/serializers/paging.py b/engine/apps/api/serializers/paging.py index 7ebaf9a4..b764f7ef 100644 --- a/engine/apps/api/serializers/paging.py +++ b/engine/apps/api/serializers/paging.py @@ -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") diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 736ff69c..0bd09c83 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -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, diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index e2f42d39..277a8be7 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -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 diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index c435dc99..5f4464b2 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -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 diff --git a/engine/apps/api/tests/test_maintenance.py b/engine/apps/api/tests/test_maintenance.py deleted file mode 100644 index 1371608b..00000000 --- a/engine/apps/api/tests/test_maintenance.py +++ /dev/null @@ -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 diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index ffd52356..4c7e67bd 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -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", diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 9bcb3d8e..2e213ae7 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -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]) diff --git a/engine/apps/api/views/maintenance.py b/engine/apps/api/views/maintenance.py deleted file mode 100644 index 1617b207..00000000 --- a/engine/apps/api/views/maintenance.py +++ /dev/null @@ -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) diff --git a/engine/apps/public_api/serializers/organizations.py b/engine/apps/public_api/serializers/organizations.py index 4df06f13..f1e88544 100644 --- a/engine/apps/public_api/serializers/organizations.py +++ b/engine/apps/public_api/serializers/organizations.py @@ -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"] diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index 3a4a2db3..6b75c897 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -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, diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index eac090eb..5bcb92fb 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -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): diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index b07b0598..19f41a18 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -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: diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index fa2b8b2d..caa19d82 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -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 diff --git a/engine/config_integrations/direct_paging.py b/engine/config_integrations/direct_paging.py index 40d6e791..755c1cd7 100644 --- a/engine/config_integrations/direct_paging.py +++ b/engine/config_integrations/direct_paging.py @@ -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 diff --git a/engine/settings/base.py b/engine/settings/base.py index 5b793d27..0664ce0f 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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") diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 9a8d2b07..b58173bb 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -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', diff --git a/grafana-plugin/integration-tests/integrations/maintenanceMode.test.ts b/grafana-plugin/integration-tests/integrations/maintenanceMode.test.ts new file mode 100644 index 00000000..df73c7cd --- /dev/null +++ b/grafana-plugin/integration-tests/integrations/maintenanceMode.test.ts @@ -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 => { + 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 => { + 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 => { + 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); + }); +}); diff --git a/grafana-plugin/integration-tests/utils/alertGroup.ts b/grafana-plugin/integration-tests/utils/alertGroup.ts index 385b3cbd..1a72d941 100644 --- a/grafana-plugin/integration-tests/utils/alertGroup.ts +++ b/grafana-plugin/integration-tests/utils/alertGroup.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName); expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true); }; diff --git a/grafana-plugin/integration-tests/utils/forms.ts b/grafana-plugin/integration-tests/utils/forms.ts index fb00b9dd..06492210 100644 --- a/grafana-plugin/integration-tests/utils/forms.ts +++ b/grafana-plugin/integration-tests/utils/forms.ts @@ -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 => { + 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; }; diff --git a/grafana-plugin/integration-tests/utils/integrations.ts b/grafana-plugin/integration-tests/utils/integrations.ts index c903f3ad..a8e96a2c 100644 --- a/grafana-plugin/integration-tests/utils/integrations.ts +++ b/grafana-plugin/integration-tests/utils/integrations.ts @@ -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 => { await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR); }; -export const createIntegrationAndSendDemoAlert = async ( - page: Page, - integrationName: string, - _escalationChainName: string -): Promise => { +export const createIntegration = async (page: Page, integrationName: string): Promise => { 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 => { + 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 => { + 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 => { + await createIntegration(page, integrationName); + await assignEscalationChainToIntegration(page, escalationChainName); + await sendDemoAlert(page); +}; + +export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integrationName: string): Promise => { + // 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(); }; diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 424a03e3..aa5657dc 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -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: { diff --git a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx index 384a3016..18503e43 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx +++ b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.tsx @@ -12,7 +12,7 @@ interface IntegrationBlockItemProps { const IntegrationBlockItem: React.FC = (props) => { return ( -
+
{props.children}
diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts index 5ce80b2a..e4a5f0cd 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts @@ -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, - }, - }, ], }; diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css index 2b167d9c..a421dabc 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css @@ -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%; + } +} diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx index 274b84dd..255f3ea8 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx @@ -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 = (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(); + const [selectedTeamDirectPaging, setSelectedTeamDirectPaging] = useState(); + const [directPagingLoading, setdirectPagingLoading] = useState(); + + const [chatOpsAvailableChannels, setChatopsAvailableChannels] = useState(); + + 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 = (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 ( + + {selectedTeamId && + (directPagingLoading ? ( + + ) : selectedTeamDirectPaging ? ( + + +
    +
  • + + + {escalationChainsExist && ( + + + + )} + {selectedTeamDirectPaging.verbal_name} + + + Team: + + + + {chatOpsAvailableChannels && ( + <> + ChatOps:{' '} + {chatOpsAvailableChannels.map( + (chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => ( +
    + {chatOpsChannel.icon && } + {chatOpsChannel.name || ''} +
    + ) + )} + + )} +
    + + + + + +
    +
  • +
+ + {(escalationChainsExist || !chatOpsAvailableChannels) && ( + + + {escalationChainsExist && ( + + Integration doesn't have connected escalation policies. Consider adding responders manually by + user or by email + + )} + {!chatOpsAvailableChannels && ( + Integration doesn't have connected ChatOps channels in messengers. + )} + + + )} +
+ ) : ( + + + + Empty integration for this team will be created automatically. Consider selecting responders by + schedule or user below + + + + ))} +
+ ); + }; + + const submitButtonDisabled = !( + selectedTeamId && + (selectedTeamDirectPaging || userResponders.length || scheduleResponders.length) + ); + return ( - <> - - - - - {store.teamStore.currentTeam.slack_team_identity && ( - - {' '} - - The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack - channel. - - - )} - - - - - - - + + + + + + + + + + + + + + ); }; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.scss b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.scss index 1cfd657b..b4e2e8cf 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.scss +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.scss @@ -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); } diff --git a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx index cdbef993..b3eb8e72 100644 --- a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx +++ b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx @@ -24,7 +24,10 @@ interface TooltipBadgeProps { const cx = cn.bind(styles); const TooltipBadge: FC = (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 ( = (props) => { className )} onMouseEnter={onHover} + {...(testId ? { 'data-testid': testId } : {})} > {renderIcon()} - {text && {text}} + {text && ( + + {text} + + )}
diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index f8a4cf39..04440d08 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -51,6 +51,8 @@ display: flex; flex-direction: column; gap: 1px; + max-height: calc(100vh - 600px); + overflow: scroll; } .user { diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index b0d4c6bb..6c700d8e 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -33,8 +33,6 @@ const SortableHandleHoc = SortableHandle(DragHandle); const UserGroups = (props: UserGroupsProps) => { const { value, onChange, isMultipleGroups, renderUser, showError, disabled } = props; - const rootRef = useRef(); - 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) => (
  • {renderUser(item.data)} @@ -123,7 +110,7 @@ const UserGroups = (props: UserGroupsProps) => { ); return ( -
    +
    {!disabled && ( ( ({ items, handleAddGroup, isMultipleGroups, renderItem, allowCreate }) => { + const listRef = useRef(); + + useEffect(() => { + const container = listRef.current; + + container.scroll({ + left: 0, + top: container.scrollHeight, + behavior: 'smooth', + }); + }, [items]); + return ( -
      +
        {items.map((item, index) => item.type === 'item' ? ( diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index 4c7254be..448d20b4 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -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 = (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 = (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})`; diff --git a/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx b/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx index a3847476..a5fe4ab2 100644 --- a/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx +++ b/grafana-plugin/src/components/WithContextMenu/WithContextMenu.tsx @@ -16,6 +16,7 @@ export const WithContextMenu: React.FC = ({ 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 = ({ }, []); return ( - <> +
        {children({ openMenu: (e) => { setIsMenuOpen(true); @@ -56,6 +57,6 @@ export const WithContextMenu: React.FC = ({ focusOnOpen={focusOnOpen} /> )} - +
        ); }; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index effc7952..659a51c3 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -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 = (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 = (props) => { - + - - + + {nonWorkingMoments && @@ -56,7 +56,7 @@ const WorkingHours: FC = (props) => { y={0} width={`${(diff * 100) / duration}%`} height="100%" - fill={`${strong ? 'url(#stripes_strong)' : 'url(#stripes)'}`} + fill={light ? 'url(#stripes_light)' : 'url(#stripes)'} /> ); })} diff --git a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx b/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx index c4177cad..98ee9d81 100644 --- a/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx +++ b/grafana-plugin/src/containers/CreateAlertReceiveChannelContainer/CreateAlertReceiveChannelContainer.tsx @@ -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. " > - +

    diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx index 07ca0b8e..c03b19b8 100644 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx +++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx @@ -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(
    {!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && ( <> - +
      {value.userResponders.map((responder, index) => ( )}
      + {withLabels && }
      @@ -230,11 +233,11 @@ const UserResponder = ({ important, data, onImportantChange, handleDelete }) => }} onChange={onImportantChange} /> - notification chain + notification policies ) : ( - + diff --git a/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx b/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx index fff5cc91..4f7bebe0 100644 --- a/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx +++ b/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx @@ -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(user.current_team); + const [selectedTeam, setSelectedTeam] = useState(defaultValue); const grafanaTeams = grafanaTeamStore.getSearchResult(); diff --git a/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx index 9062d98b..6c4bb66b 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx @@ -145,7 +145,9 @@ const CollapsedIntegrationRouteDisplay: React.FC
    - Not selected + + Not selected +
    )}
  • diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx index d550d6c9..543b4c79 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx @@ -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(() => { diff --git a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.config.tsx b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.config.tsx index 2e6c8ccd..60a1729f 100644 --- a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.config.tsx +++ b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.config.tsx @@ -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, diff --git a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx index 78f7362f..33814c75 100644 --- a/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx +++ b/grafana-plugin/src/containers/MaintenanceForm/MaintenanceForm.tsx @@ -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 ( -
    +
    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 - diff --git a/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.module.css b/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.module.css deleted file mode 100644 index 913c13e0..00000000 --- a/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.module.css +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.tsx b/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.tsx deleted file mode 100644 index 2e0831a8..00000000 --- a/grafana-plugin/src/containers/OrganizationLogFilters/OrganizationLogFilters.tsx +++ /dev/null @@ -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(); - - const onSearchTermChangeCallback = useCallback( - (e: ChangeEvent) => { - 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 ( -
    - - - - - -
    - ); -}); - -export default OrganizationLogFilters; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css index 803aad27..0e03a13c 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -1,6 +1,4 @@ .body { - max-height: calc(100vh - 300px); - overflow: scroll; margin: 15px -15px; padding: 15px 0; border-top: var(--border-medium); diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 0f2c2058..2b79241a 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -77,7 +77,7 @@ const UsersTimezones: FC = (props) => { return (
    - - diff --git a/grafana-plugin/src/icons/grafana-icon.svg b/grafana-plugin/src/icons/grafana-icon.svg deleted file mode 100644 index 15ccfa28..00000000 --- a/grafana-plugin/src/icons/grafana-icon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/grafana-plugin/src/icons/heart-green.svg b/grafana-plugin/src/icons/heart-green.svg deleted file mode 100644 index 19b895a6..00000000 --- a/grafana-plugin/src/icons/heart-green.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/grafana-plugin/src/icons/heart-line.svg b/grafana-plugin/src/icons/heart-line.svg deleted file mode 100644 index 6c063e81..00000000 --- a/grafana-plugin/src/icons/heart-line.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/grafana-plugin/src/icons/heart-red.svg b/grafana-plugin/src/icons/heart-red.svg deleted file mode 100644 index ab685063..00000000 --- a/grafana-plugin/src/icons/heart-red.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index 3e0141c8..5bba570e 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -1,156 +1,6 @@ import React from 'react'; -interface IconProps {} - -export const NewIncidentIcon = (_props: IconProps) => ( - - - - - -); - -export const AcknowledgedIncidentIcon = (_props: IconProps) => ( - - - - - - -); - -export const SilencedIncidentIcon = (_props: IconProps) => ( - - - - - - -); - -export const SentryIcon = (_props: IconProps) => ( - - - - - -); - -export const SilencedIncidentIconTransparent = (_props: IconProps) => ( - - - - - - -); - -export const CurlerIcon = (_props: IconProps) => ( - - - -); - -export const TelegramIcon = (_props: IconProps) => ( - - - -); - -export const AmixrIcon = (_props: IconProps) => ( - - - - -); - -export const AmixrIconColored = (_props: IconProps) => ( - - - - -); - -export const HeartGreenIcon = (_props: IconProps) => ( +export const HeartGreenIcon = () => ( ( ); -export const HeartRedIcon = (_props: IconProps) => ( +export const HeartRedIcon = () => ( ( ); -export const HeartIcon = (_props: IconProps) => ( +export const HeartIcon = () => ( ( ); -export const CrossCircleIcon = (_props: IconProps) => ( +export const CrossCircleIcon = () => ( ( ); -export const ChangeTeamIcon = (_props: IconProps) => ( - - - -); - -export const GrafanaIcon = (_props: IconProps) => ( - - - - - - - - - - -); - -export const ExpandIcon = (_props: IconProps) => { +export const ExpandIcon = () => { return ( { ); }; + export const SlackNewIcon = () => ( { - return qs.stringify(params, { arrayFormat: 'none' }); - }; - - return { - ...config, - withCredentials: true, - }; -}); diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index 51769285..fb81d578 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -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 => + makeRequest(`${this.path}${id}/start_maintenance/`, { + method: 'POST', + data: { + mode, + duration, + }, + }); + + stopMaintenanceMode = (id: AlertReceiveChannel['id']) => + makeRequest(`${this.path}${id}/stop_maintenance/`, { + method: 'POST', + }); } diff --git a/grafana-plugin/src/models/card.ts b/grafana-plugin/src/models/card.ts deleted file mode 100644 index c2af07c4..00000000 --- a/grafana-plugin/src/models/card.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/models/curler/curler.ts b/grafana-plugin/src/models/curler/curler.ts deleted file mode 100644 index f1e8d994..00000000 --- a/grafana-plugin/src/models/curler/curler.ts +++ /dev/null @@ -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 } = {}; - - @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); - } -} diff --git a/grafana-plugin/src/models/curler/curler.types.ts b/grafana-plugin/src/models/curler/curler.types.ts deleted file mode 100644 index db8dc6ab..00000000 --- a/grafana-plugin/src/models/curler/curler.types.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/models/current_subscription.ts b/grafana-plugin/src/models/current_subscription.ts deleted file mode 100644 index 547bcf60..00000000 --- a/grafana-plugin/src/models/current_subscription.ts +++ /dev/null @@ -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; - }; - }; -} diff --git a/grafana-plugin/src/models/current_subscription/current_subscription.ts b/grafana-plugin/src/models/current_subscription/current_subscription.ts deleted file mode 100644 index 527ed192..00000000 --- a/grafana-plugin/src/models/current_subscription/current_subscription.ts +++ /dev/null @@ -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, {}); - } -} diff --git a/grafana-plugin/src/models/current_subscription/current_subscription.types.ts b/grafana-plugin/src/models/current_subscription/current_subscription.types.ts deleted file mode 100644 index 7a52972a..00000000 --- a/grafana-plugin/src/models/current_subscription/current_subscription.types.ts +++ /dev/null @@ -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; - }; - }; -} diff --git a/grafana-plugin/src/models/integrations_list.ts b/grafana-plugin/src/models/integrations_list.ts deleted file mode 100644 index fd13f332..00000000 --- a/grafana-plugin/src/models/integrations_list.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IntegrationsListDTO { - docs_url: string; - logo_url: string; - id: string; - name: string; -} diff --git a/grafana-plugin/src/models/leader.ts b/grafana-plugin/src/models/leader.ts deleted file mode 100644 index 1f90ca06..00000000 --- a/grafana-plugin/src/models/leader.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/models/maintenance/maintenance.ts b/grafana-plugin/src/models/maintenance/maintenance.ts deleted file mode 100644 index 2a56b4ba..00000000 --- a/grafana-plugin/src/models/maintenance/maintenance.ts +++ /dev/null @@ -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, - }); - } -} diff --git a/grafana-plugin/src/models/maintenance/maintenance.types.ts b/grafana-plugin/src/models/maintenance/maintenance.types.ts deleted file mode 100644 index a57c6727..00000000 --- a/grafana-plugin/src/models/maintenance/maintenance.types.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/models/organization_log/organization_log.ts b/grafana-plugin/src/models/organization_log/organization_log.ts deleted file mode 100644 index 857317b8..00000000 --- a/grafana-plugin/src/models/organization_log/organization_log.ts +++ /dev/null @@ -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; - }; - - 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]), - }; - } -} diff --git a/grafana-plugin/src/models/organization_log/organization_log.types.ts b/grafana-plugin/src/models/organization_log/organization_log.types.ts deleted file mode 100644 index e63da78f..00000000 --- a/grafana-plugin/src/models/organization_log/organization_log.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { User } from 'models/user/user.types'; - -export interface OrganizationLog { - id: string; - author: Partial; - type: number; - created_at: string; - description: string; - labels: string[]; -} diff --git a/grafana-plugin/src/models/timezone/timezone.helpers.ts b/grafana-plugin/src/models/timezone/timezone.helpers.ts index 688b409b..db962454 100644 --- a/grafana-plugin/src/models/timezone/timezone.helpers.ts +++ b/grafana-plugin/src/models/timezone/timezone.helpers.ts @@ -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; diff --git a/grafana-plugin/src/models/timezone/timezone.types.ts b/grafana-plugin/src/models/timezone/timezone.types.ts index 8c16ffb0..5a24347f 100644 --- a/grafana-plugin/src/models/timezone/timezone.types.ts +++ b/grafana-plugin/src/models/timezone/timezone.types.ts @@ -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]; diff --git a/grafana-plugin/src/models/webinar/webinar.ts b/grafana-plugin/src/models/webinar/webinar.ts deleted file mode 100644 index 134d6a5d..00000000 --- a/grafana-plugin/src/models/webinar/webinar.ts +++ /dev/null @@ -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 }; - - @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; - } -} diff --git a/grafana-plugin/src/models/webinar/webinar.types.ts b/grafana-plugin/src/models/webinar/webinar.types.ts deleted file mode 100644 index 7d802143..00000000 --- a/grafana-plugin/src/models/webinar/webinar.types.ts +++ /dev/null @@ -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; - subscribed: boolean; -} diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index b4a91ed2..f26f8bb4 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -111,7 +111,7 @@ class Incidents extends React.Component 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 Alert Groups @@ -142,6 +142,7 @@ class Incidents extends React.Component onCreate={(id: Alert['pk']) => { history.push(`${PLUGIN_ROOT}/alert-groups/${id}`); }} + alertReceiveChannelStore={alertReceiveChannelStore} /> )} diff --git a/grafana-plugin/src/pages/index.tsx b/grafana-plugin/src/pages/index.tsx index 9ada4824..48e9a989 100644 --- a/grafana-plugin/src/pages/index.tsx +++ b/grafana-plugin/src/pages/index.tsx @@ -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'], diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 0bd83838..550dce15 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -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 { Autoresolve: - {IntegrationHelper.truncateLine(templates['resolve_condition_template'] || '')} + {IntegrationHelper.truncateLine(templates['resolve_condition_template'] || 'disabled')}
    @@ -606,7 +605,7 @@ class Integration extends React.Component { const DemoNotification: React.FC = () => { return ( -
    +
    Demo alert was generated. Find it on the "Alert Groups" page and make sure it didn't freak out your colleagues 😉 @@ -727,7 +726,7 @@ const IntegrationActions: React.FC = ({ alertReceiveChannel, changeIsTemplateSettingsOpen, }) => { - const { maintenanceStore, alertReceiveChannelStore, heartbeatStore } = useStore(); + const { alertReceiveChannelStore, heartbeatStore } = useStore(); const history = useHistory(); @@ -815,6 +814,7 @@ const IntegrationActions: React.FC = ({ (
    openIntegrationSettings()}> @@ -831,7 +831,11 @@ const IntegrationActions: React.FC = ({ {!alertReceiveChannel.maintenance_till && ( -
    +
    Start Maintenance
    @@ -862,6 +866,7 @@ const IntegrationActions: React.FC = ({ ), }); }} + data-testid="integration-stop-maintenance" > Stop Maintenance
    @@ -941,14 +946,13 @@ const IntegrationActions: React.FC = ({ 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 ( = ({ id className={cx('how-to-connect__tag')} > - {item?.inbound_email ? 'Inbound Email' : 'HTTP Endpoint'} + {howToConnectTagName(item?.integration)} - {url && ( - + {item?.integration === 'direct_paging' ? ( + <> + Alert Groups raised manually via Web or ChatOps + + + + How it works + + + + + + ) : ( + <> + {url && ( + + )} + + + + How to connect + + + + + )} - - - - How to connect - - - -
    } 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 try to raise a demo alert group via Web or Chatops; + } else { + return item.demo_alert_enabled && ; try to send a demo alert; + } + }; + return ( {!hasAlerts && ( - No alerts yet; try to send a demo alert + No alerts yet; {callToAction()} )} @@ -1063,6 +1107,7 @@ const IntegrationHeader: React.FC = ({ {alertReceiveChannel.maintenance_till && ( /> { - 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 { 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 ( <> -
    - ( -
    -
    - - - Maintenance - - - Mute noisy sources or use for debugging and avoid bothering your colleagues. - - -
    - - - -
    - )} - rowKey="id" - columns={columns} - data={data} - /> -
    - {maintenanceData && ( - { - this.setState({ maintenanceData: undefined }); - }} - /> - )} + + Maintenance mode is now controlled at the{' '} + Integration 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 ; - - case MaintenanceType.organization: - return `${store.teamStore.currentTeam?.name} Team`; - } - }; - - renderMode = (maintenance: Maintenance) => { - return maintenance.maintenance_mode === MaintenanceMode.Debug ? 'Debug' : 'Maintenance'; - }; - - renderActionButtons = (maintenance: Maintenance) => { - return ( -
    - - - - - -
    - ); - }; - - 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; diff --git a/grafana-plugin/src/pages/organization-logs/OrganizationLog.module.css b/grafana-plugin/src/pages/organization-logs/OrganizationLog.module.css deleted file mode 100644 index 805f05ea..00000000 --- a/grafana-plugin/src/pages/organization-logs/OrganizationLog.module.css +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/pages/organization-logs/OrganizationLog.tsx b/grafana-plugin/src/pages/organization-logs/OrganizationLog.tsx deleted file mode 100644 index 66a7d5b5..00000000 --- a/grafana-plugin/src/pages/organization-logs/OrganizationLog.tsx +++ /dev/null @@ -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 { - 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 ( -
    - - ( -
    - - Organization Logs - - -
    - )} - 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, - }} - /> -
    - ); - } - - 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
    {item.description}
    ; - }; - - renderFullDescription = (item: OrganizationLog) => { - return ( -
    - ); - }; - - renderUser = (item: OrganizationLog) => { - if (!item.author) { - return ( - - - - ); - } - - return ( - - - - - - - - ); - }; - - renderLabels = (item: OrganizationLog) => { - if (!item.labels) { - return null; - } - - return ( - - {item.labels.map((label) => ( - - ))} - - ); - }; - - renderCreatedAt = (item: OrganizationLog) => { - return moment(item.created_at).toString(); - }; -} - -export default withMobXProviderContext(OrganizationLogPage); diff --git a/grafana-plugin/src/pages/routes.tsx b/grafana-plugin/src/pages/routes.tsx index 79699577..6bacfe95 100644 --- a/grafana-plugin/src/pages/routes.tsx +++ b/grafana-plugin/src/pages/routes.tsx @@ -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', diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 0980b4fc..6ecc03e5 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -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) => { - + - - - diff --git a/grafana-plugin/src/services/experimentManager.ts b/grafana-plugin/src/services/experimentManager.ts deleted file mode 100644 index 8eb8ac67..00000000 --- a/grafana-plugin/src/services/experimentManager.ts +++ /dev/null @@ -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'; -} diff --git a/grafana-plugin/src/services/googleTagManager.ts b/grafana-plugin/src/services/googleTagManager.ts deleted file mode 100644 index 301e4d47..00000000 --- a/grafana-plugin/src/services/googleTagManager.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function pushConvertion(message: any) { - if (window.dataLayer) { - window.dataLayer.push(message); - } -} diff --git a/grafana-plugin/src/services/urlManager.ts b/grafana-plugin/src/services/urlManager.ts deleted file mode 100644 index e9554b0a..00000000 --- a/grafana-plugin/src/services/urlManager.ts +++ /dev/null @@ -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; -} diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index eaf8804a..3cb525f7 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -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 diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index daffb8d1..c10443b3 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -22,17 +22,7 @@ --tag-primary: #299c46; --tag-secondary: #464c54; --tag-secondary-transparent: rgba(204, 204, 220, 0.07); - --tag-background-primary: rgba(56, 113, 220, 0.2); - --tag-border-primary: rgba(56, 113, 220, 0.2); - --tag-background-danger: rgba(242, 73, 92, 0.15); - --tag-text-primary: rgba(110, 159, 255, 1); - --tag-border-danger: rgb(151, 11, 27); - --tag-text-danger: rgb(247, 144, 156); - --tag-border-warning: rgb(150, 75, 0); - --tag-background-warning: rgba(245, 183, 61, 0.18); - --tag-text-warning: rgb(255, 190, 124); --tag-border-link: rgba(56, 113, 220, 0.2); - --tag-background-success: rgba(27, 133, 94, 0.15); } .theme-light { @@ -62,11 +52,23 @@ --shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.2); --shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.2); --shadows-z3: 0 13px 20px 1px rgba(24, 26, 27, 0.18); - --tag-text-success: rgb(50, 96, 43); + --tag-background-primary: rgba(50, 116, 217, 0.15); + --tag-border-primary: rgb(136, 174, 233); + --tag-text-primary: rgb(26, 71, 139); + --tag-background-warning: rgba(255, 120, 10, 0.15); + --tag-border-warning: rgb(255, 176, 112); + --tag-text-warning: rgb(163, 73, 0); + --tag-background-success: rgba(86, 166, 75, 0.15); --tag-border-success: rgb(148, 203, 140); + --tag-text-success: rgb(50, 96, 43); + --tag-background-danger: rgba(224, 47, 68, 0.15); + --tag-border-danger: rgb(237, 136, 148); + --tag-text-danger: rgb(147, 22, 37); --button-background: rgba(36, 41, 46, 0.08); --button-hover-background: rgba(36, 41, 46, 0.15); --box-background: rgba(244, 245, 245); + --working-hours-shades-color: rgba(17, 18, 23, 0.15); + --working-hours-shades-color-light: rgba(17, 18, 23, 0.04); } .theme-dark { @@ -98,9 +100,21 @@ --shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.75); --shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.75); --shadows-z3: 0 8px 24px rgb(1, 4, 9); - --tag-text-success: rgb(165, 214, 159); + --tag-background-primary: rgba(87, 148, 242, 0.15); + --tag-border-primary: rgb(13, 72, 163); + --tag-text-primary: rgb(158, 193, 247); + --tag-background-warning: rgba(255, 152, 48, 0.15); + --tag-border-warning: rgb(150, 75, 0); + --tag-text-warning: rgb(255, 190, 124); + --tag-background-success: rgba(115, 191, 105, 0.15); --tag-border-success: rgb(49, 100, 43); + --tag-text-success: rgb(165, 214, 159); + --tag-background-danger: rgba(242, 73, 92, 0.15); + --tag-border-danger: rgb(151, 11, 27); + --tag-text-danger: rgb(247, 144, 156); --button-background: rgba(204, 204, 220, 0.1); --button-hover-background: rgba(204, 204, 220, 0.14); --box-background: rgba(10, 10, 10, 0.4); + --working-hours-shades-color: rgba(17, 18, 23, 0.15); + --working-hours-shades-color-light: rgba(17, 18, 23, 0.1); } diff --git a/grafana-plugin/src/utils/index.ts b/grafana-plugin/src/utils/index.ts index 24891701..c196f3cf 100644 --- a/grafana-plugin/src/utils/index.ts +++ b/grafana-plugin/src/utils/index.ts @@ -6,6 +6,8 @@ import appEvents from 'grafana/app/core/app_events'; import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es'; import qs from 'query-string'; +import { isNetworkError } from 'network'; + export class KeyValuePair { key: T; value: string; @@ -23,7 +25,7 @@ export const getTzOffsetHours = (): number => { }; export function showApiError(error: any) { - if (error.response.status >= 400 && error.response.status < 500) { + if (isNetworkError(error) && error.response && error.response.status >= 400 && error.response.status < 500) { const payload = error.response.data; const text = typeof payload === 'string' @@ -38,7 +40,7 @@ export function showApiError(error: any) { } export function refreshPageError(error: AxiosError) { - if (error.response?.status === 502) { + if (isNetworkError(error) && error.response?.status === 502) { const payload = error.response.data; const text = `Try to refresh the page. ${payload}`; openErrorNotification(text); @@ -48,7 +50,7 @@ export function refreshPageError(error: AxiosError) { } export function throttlingError(error: AxiosError) { - if (error.response?.status === 429) { + if (isNetworkError(error) && error.response?.status === 429) { const seconds = Number(error.response?.headers['retry-after']); const minutes = Math.floor(seconds / 60); const text = diff --git a/helm/oncall/templates/_helpers.tpl b/helm/oncall/templates/_helpers.tpl index e58f9edc..4cf593ee 100644 --- a/helm/oncall/templates/_helpers.tpl +++ b/helm/oncall/templates/_helpers.tpl @@ -92,6 +92,8 @@ Create the name of the service account to use command: ['sh', '-c', "until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done"] securityContext: {{ toYaml .Values.init.securityContext | nindent 4 }} + resources: + {{ toYaml .Values.init.resources | nindent 4 }} env: {{- include "snippet.oncall.env" . | nindent 4 }} {{- include "snippet.mysql.env" . | nindent 4 }} @@ -107,6 +109,8 @@ Create the name of the service account to use command: ['sh', '-c', "until (python manage.py migrate --check); do echo Waiting for database migrations; sleep 2; done"] securityContext: {{ toYaml .Values.init.securityContext | nindent 4 }} + resources: + {{ toYaml .Values.init.resources | nindent 4 }} env: {{- include "snippet.oncall.env" . | nindent 4 }} {{- include "snippet.postgresql.env" . | nindent 4 }} diff --git a/helm/oncall/tests/__snapshot__/wait_for_db_test.yaml.snap b/helm/oncall/tests/__snapshot__/wait_for_db_test.yaml.snap index 7d764bde..fc8d400e 100644 --- a/helm/oncall/tests/__snapshot__/wait_for_db_test.yaml.snap +++ b/helm/oncall/tests/__snapshot__/wait_for_db_test.yaml.snap @@ -71,6 +71,13 @@ database.type=mysql -> should create initContainer for MySQL database (default): image: grafana/oncall:v1.2.36 imagePullPolicy: Always name: wait-for-db + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi securityContext: {} 2: | - command: @@ -144,6 +151,13 @@ database.type=mysql -> should create initContainer for MySQL database (default): image: grafana/oncall:v1.2.36 imagePullPolicy: Always name: wait-for-db + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi securityContext: {} database.type=postgresql -> should create initContainer for PostgreSQL database: 1: | @@ -220,6 +234,13 @@ database.type=postgresql -> should create initContainer for PostgreSQL database: image: grafana/oncall:v1.2.36 imagePullPolicy: Always name: wait-for-db + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi securityContext: {} 2: | - command: @@ -295,4 +316,11 @@ database.type=postgresql -> should create initContainer for PostgreSQL database: image: grafana/oncall:v1.2.36 imagePullPolicy: Always name: wait-for-db + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi securityContext: {} diff --git a/helm/oncall/tests/wait_for_db_test.yaml b/helm/oncall/tests/wait_for_db_test.yaml index e43b1ef8..9a8bdf8e 100644 --- a/helm/oncall/tests/wait_for_db_test.yaml +++ b/helm/oncall/tests/wait_for_db_test.yaml @@ -8,6 +8,14 @@ chart: appVersion: v1.2.36 tests: - it: database.type=mysql -> should create initContainer for MySQL database (default) + set: + init.resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi asserts: - contains: path: spec.template.spec.initContainers @@ -26,6 +34,13 @@ tests: set: database.type: postgresql externalPostgresql.host: some-postgresql-host + init.resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi asserts: - contains: path: spec.template.spec.initContainers diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index 079ae51f..63963c08 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -422,3 +422,10 @@ init: # runAsGroup: 1337 # runAsNonRoot: true # runAsUser: 1337 + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi