From d24dc4b630d97eb382a77d79cf577a189eb28f76 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 12 Jul 2023 22:41:44 +0200 Subject: [PATCH] remove organization maintenance mode + fix integration maintenance mode (#2511) --- CHANGELOG.md | 14 +- .../escalation_snapshot_mixin.py | 4 +- engine/apps/alerts/models/alert.py | 4 +- engine/apps/alerts/models/alert_group.py | 8 - engine/apps/alerts/tasks/distribute_alert.py | 7 +- engine/apps/alerts/tasks/maintenance.py | 20 -- .../tasks/send_update_log_report_signal.py | 6 +- engine/apps/alerts/tests/test_maintenance.py | 72 ----- engine/apps/api/serializers/organization.py | 5 - .../apps/api/tests/test_escalation_policy.py | 4 +- engine/apps/api/tests/test_maintenance.py | 272 ------------------ engine/apps/api/urls.py | 4 - engine/apps/api/views/maintenance.py | 161 ----------- .../public_api/serializers/organizations.py | 12 +- .../apps/slack/scenarios/distribute_alerts.py | 7 +- .../user_management/models/organization.py | 38 +-- .../integrations/maintenanceMode.test.ts | 144 ++++++++++ .../integration-tests/utils/alertGroup.ts | 69 ++++- .../integration-tests/utils/forms.ts | 15 +- .../integration-tests/utils/integrations.ts | 77 +++-- grafana-plugin/playwright.config.ts | 4 + .../Integrations/IntegrationBlockItem.tsx | 2 +- .../components/TooltipBadge/TooltipBadge.tsx | 15 +- .../WithContextMenu/WithContextMenu.tsx | 5 +- .../CollapsedIntegrationRouteDisplay.tsx | 4 +- .../MaintenanceForm.config.tsx | 4 +- .../MaintenanceForm/MaintenanceForm.tsx | 33 +-- .../alert_receive_channel.ts | 15 + .../src/models/maintenance/maintenance.ts | 55 ---- .../models/maintenance/maintenance.types.ts | 19 -- .../src/pages/integration/Integration.tsx | 25 +- .../src/pages/integrations/Integrations.tsx | 1 + .../src/state/rootBaseStore/index.ts | 2 - grafana-plugin/src/utils/index.ts | 8 +- 34 files changed, 371 insertions(+), 764 deletions(-) delete mode 100644 engine/apps/api/tests/test_maintenance.py delete mode 100644 engine/apps/api/views/maintenance.py create mode 100644 grafana-plugin/integration-tests/integrations/maintenanceMode.test.ts delete mode 100644 grafana-plugin/src/models/maintenance/maintenance.ts delete mode 100644 grafana-plugin/src/models/maintenance/maintenance.types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b53a492..53d9bc95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,18 +15,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) - + within a single integration's page. by @Ukochka ([#2497](https://github.com/grafana/oncall/issues/2497)) + ### Fixed -- 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) +- 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 @@ -83,7 +85,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/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/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..ca93602a 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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/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_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/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/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/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/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/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/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/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/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/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/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/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/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 0bd83838..a8f178a6 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'; @@ -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); } }; @@ -1063,6 +1067,7 @@ const IntegrationHeader: React.FC = ({ {alertReceiveChannel.maintenance_till && ( /> { 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 =