diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d39e75..7fe15105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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.58 (2023-11-14) + +### Added + +- Added user timezone field to the users public API response ([#3311](https://github.com/grafana/oncall/pull/3311)) +- Allow filtering users by public primary key in internal API ([#3339](https://github.com/grafana/oncall/pull/3339)) + +### Changed + +- Split Integrations table into Connections and Direct Paging tabs ([#3290](https://github.com/grafana/oncall/pull/3290)) + +### Fixed + +- Fix issue where Slack user connection error message is sometimes shown despite successful connection by @joeyorlando ([#3327](https://github.com/grafana/oncall/pull/3327)) +- Forward headers for Amazon SNS when organizations are moved @mderynck ([#3326](https://github.com/grafana/oncall/pull/3326)) +- Fix styling when light theme is turned on via system preferences + by excluding dark theme css vars in this case ([#3336](https://github.com/grafana/oncall/pull/3336)) +- Fix issue when acknowledge reminder works for deleted organizations @Ferril ([#3345](https://github.com/grafana/oncall/pull/3345)) +- Fix generating QR code ([#3347](https://github.com/grafana/oncall/pull/3347)) + ## v1.3.57 (2023-11-10) ### Fixed diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index fe9ed57a..1b0df9cf 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -90,7 +90,7 @@ will be sent to users. #### Heartbeat monitoring An OnCall heartbeat acts as a healthcheck for alert group monitoring. You can configure you monitoring to regularly send alerts -to the heartbeat endpoint. If OnCall doen't receive one of these alerts, it will create an new alert group and escalate it +to the heartbeat endpoint. If OnCall doesn't receive one of these alerts, it will create an new alert group and escalate it 1. Go to Integration page and click **Three dots** 1. Select **Heartbeat Settings** diff --git a/docs/sources/on-call-schedules/web-schedule/_index.md b/docs/sources/on-call-schedules/web-schedule/_index.md index e29d7cad..d77104ef 100644 --- a/docs/sources/on-call-schedules/web-schedule/_index.md +++ b/docs/sources/on-call-schedules/web-schedule/_index.md @@ -51,7 +51,7 @@ on this calendar will take precedence over the rotations calendar. ## Schedule quality report -The schedule view features a quality report that provides a score for your schedule based on rotations and overrides. +The schedule view features a quality report that provides a score for your schedule based on rotations, overrides and [shift swaps][shift-swaps]. It's calculated based on these key factors: - Gaps (amount of time when no one is on-call) @@ -85,3 +85,8 @@ A perfectly balanced schedule is considered ideal, so reducing this number will Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. The schedule export allows you to view on-call shifts alongside the rest of your schedule. + +{{% docs/reference %}} +[shift-swaps]: "/docs/oncall/ -> /docs/oncall//on-call-schedules/shift-swaps" +[shift-swaps]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/on-call-schedules/shift-swaps" +{{% /docs/reference %}} diff --git a/docs/sources/oncall-api-reference/users.md b/docs/sources/oncall-api-reference/users.md index b3e58821..04adcf1d 100644 --- a/docs/sources/oncall-api-reference/users.md +++ b/docs/sources/oncall-api-reference/users.md @@ -28,7 +28,8 @@ The above command returns JSON structured in the following way: } ], "username": "alex", - "role": "admin" + "role": "admin", + "timezone": "UTC" } ``` @@ -45,6 +46,7 @@ Use `{{API_URL}}/api/v1/users/current` to retrieve the current user. | `slack` | Yes/org | List of user IDs from connected Slack. User linking key is e-mail. | | `username` | Yes/org | User username | | `role` | No | One of: `user`, `observer`, `admin`. | +| `timezone` | No | timezone of the user one of [time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). | # List Users @@ -73,7 +75,8 @@ The above command returns JSON structured in the following way: } ], "username": "alex", - "role": "admin" + "role": "admin", + "timezone": "UTC" } ], "current_page_number": 1, diff --git a/engine/apps/alerts/tasks/acknowledge_reminder.py b/engine/apps/alerts/tasks/acknowledge_reminder.py index 1f8b6f1d..dd9848ac 100644 --- a/engine/apps/alerts/tasks/acknowledge_reminder.py +++ b/engine/apps/alerts/tasks/acknowledge_reminder.py @@ -26,13 +26,11 @@ def acknowledge_reminder_task(alert_group_pk: int, unacknowledge_process_id: str if unacknowledge_process_id != alert_group.last_unique_unacknowledge_process_id: return + organization = alert_group.channel.organization + # Get timeout values - acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[ - alert_group.channel.organization.acknowledge_remind_timeout - ] - unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[ - alert_group.channel.organization.unacknowledge_timeout - ] + acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[organization.acknowledge_remind_timeout] + unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[organization.unacknowledge_timeout] # Don't proceed if the alert group is not in a state for acknowledgement reminder acknowledge_reminder_required = ( @@ -41,10 +39,18 @@ def acknowledge_reminder_task(alert_group_pk: int, unacknowledge_process_id: str and alert_group.acknowledged_by == AlertGroup.USER and acknowledge_reminder_timeout ) - if not acknowledge_reminder_required: - task_logger.info("AlertGroup is not in a state for acknowledgement reminder") + is_organization_deleted = organization.deleted_at is not None + log_info = ( + f"acknowledge_reminder_timeout option: {acknowledge_reminder_timeout}," + f"organization ppk: {organization.public_primary_key}," + f"organization is deleted: {is_organization_deleted}" + ) + if not acknowledge_reminder_required or is_organization_deleted: + task_logger.info(f"alert group {alert_group_pk} is not in a state for acknowledgement reminder. {log_info}") return + task_logger.info(f"alert group {alert_group_pk} is in a state for acknowledgement reminder. {log_info}") + # unacknowledge_timeout_task uses acknowledged_by_confirmed to check if acknowledgement reminder has been confirmed # by the user. Setting to None here to indicate that the user has not confirmed the acknowledgement reminder alert_group.acknowledged_by_confirmed = None @@ -80,13 +86,11 @@ def unacknowledge_timeout_task(alert_group_pk: int, unacknowledge_process_id: st if unacknowledge_process_id != alert_group.last_unique_unacknowledge_process_id: return + organization = alert_group.channel.organization + # Get timeout values - acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[ - alert_group.channel.organization.acknowledge_remind_timeout - ] - unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[ - alert_group.channel.organization.unacknowledge_timeout - ] + acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[organization.acknowledge_remind_timeout] + unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[organization.unacknowledge_timeout] # Don't proceed if the alert group is not in a state for auto-unacknowledge unacknowledge_required = ( @@ -96,16 +100,28 @@ def unacknowledge_timeout_task(alert_group_pk: int, unacknowledge_process_id: st and acknowledge_reminder_timeout and unacknowledge_timeout ) - if not unacknowledge_required: - task_logger.info("AlertGroup is not in a state for unacknowledge") + is_organization_deleted = organization.deleted_at is not None + log_info = ( + f"acknowledge_reminder_timeout option: {acknowledge_reminder_timeout}," + f"unacknowledge_timeout option: {unacknowledge_timeout}," + f"organization ppk: {organization.public_primary_key}," + f"organization is deleted: {is_organization_deleted}" + ) + if not unacknowledge_required or is_organization_deleted: + task_logger.info(f"alert group {alert_group_pk} is not in a state for unacknowledge by timeout. {log_info}") return if alert_group.acknowledged_by_confirmed: # acknowledgement reminder was confirmed by the user acknowledge_reminder_task.apply_async( (alert_group_pk, unacknowledge_process_id), countdown=acknowledge_reminder_timeout - unacknowledge_timeout ) + task_logger.info( + f"Acknowledgement reminder was confirmed by user. Rescheduling acknowledge_reminder_task..." + f"alert group: {alert_group_pk}, {log_info}" + ) return + task_logger.info(f"alert group {alert_group_pk} is in a state for unacknowledge by timeout. {log_info}") # If acknowledgement reminder wasn't confirmed by the user, unacknowledge the alert group and start escalation again log_record = alert_group.log_records.create( type=AlertGroupLogRecord.TYPE_AUTO_UN_ACK, author=alert_group.acknowledged_by_user diff --git a/engine/apps/alerts/tests/test_acknowledge_reminder.py b/engine/apps/alerts/tests/test_acknowledge_reminder.py index a47042fc..f66fe9af 100644 --- a/engine/apps/alerts/tests/test_acknowledge_reminder.py +++ b/engine/apps/alerts/tests/test_acknowledge_reminder.py @@ -299,3 +299,43 @@ def test_unacknowledge_timeout_task_no_unacknowledge( ) assert not alert_group.log_records.exists() + + +@patch.object(acknowledge_reminder_task, "apply_async") +@patch.object(unacknowledge_timeout_task, "apply_async") +@pytest.mark.django_db +def test_ack_reminder_skip_deleted_org( + mock_acknowledge_reminder_task, + mock_unacknowledge_timeout_task, + ack_reminder_test_setup, +): + organization, alert_group, user = ack_reminder_test_setup() + organization.deleted_at = timezone.now() + organization.save() + + acknowledge_reminder_task(alert_group.pk, TASK_ID) + + mock_unacknowledge_timeout_task.assert_not_called() + mock_acknowledge_reminder_task.assert_not_called() + + assert not alert_group.log_records.exists() + + +@patch.object(acknowledge_reminder_task, "apply_async") +@patch.object(unacknowledge_timeout_task, "apply_async") +@pytest.mark.django_db +def test_unacknowledge_timeout_task_skip_deleted_org( + mock_acknowledge_reminder_task, + mock_unacknowledge_timeout_task, + ack_reminder_test_setup, +): + organization, alert_group, user = ack_reminder_test_setup() + organization.deleted_at = timezone.now() + organization.save() + + unacknowledge_timeout_task(alert_group.pk, TASK_ID) + + mock_unacknowledge_timeout_task.assert_not_called() + mock_acknowledge_reminder_task.assert_not_called() + + assert not alert_group.log_records.exists() diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 04003de1..194a5645 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -33,6 +33,29 @@ def test_get_alert_receive_channel(alert_receive_channel_internal_api_setup, mak assert response.status_code == status.HTTP_200_OK +@pytest.mark.django_db +def test_get_alert_receive_channel_by_integration_ne( + make_organization_and_user_with_plugin_token, make_user_auth_headers, make_alert_receive_channel +): + organization, user, token = make_organization_and_user_with_plugin_token() + + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA) + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING) + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + + client = APIClient() + url = f"{reverse('api-internal:alert_receive_channel-list')}?integration_ne={AlertReceiveChannel.INTEGRATION_DIRECT_PAGING}" + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + results = response.json()["results"] + + assert response.status_code == status.HTTP_200_OK + assert len(results) == 2 + + for result in results: + assert result["integration"] != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + + @pytest.mark.django_db @pytest.mark.parametrize( "query_param,should_be_unpaginated", diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 8e69ba8c..bc876c83 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -262,6 +262,31 @@ def test_list_users_filtered_by_granted_permission( assert user3.public_primary_key not in returned_user_pks +@pytest.mark.django_db +def test_list_users_filtered_by_public_primary_key( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, +): + organization = make_organization() + admin_user = make_user_for_organization(organization) + user1 = make_user_for_organization(organization) + make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-list") + + response = client.get( + f"{url}?search={user1.public_primary_key}", format="json", **make_user_auth_headers(admin_user, token) + ) + + assert response.status_code == status.HTTP_200_OK + returned_user_pks = [u["pk"] for u in response.json()["results"]] + assert returned_user_pks == [user1.public_primary_key] + + @pytest.mark.django_db def test_notification_chain_verbal( make_organization, diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 14e1a9ce..aa79da6d 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -43,6 +43,9 @@ class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): choices=AlertReceiveChannel.MAINTENANCE_MODE_CHOICES, method="filter_maintenance_mode" ) integration = filters.MultipleChoiceFilter(choices=AlertReceiveChannel.INTEGRATION_CHOICES) + integration_ne = filters.MultipleChoiceFilter( + choices=AlertReceiveChannel.INTEGRATION_CHOICES, field_name="integration", exclude=True + ) team = TeamModelMultipleChoiceFilter() class Meta: diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 6434d57a..7b3557cb 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -222,6 +222,7 @@ class UserView( "^slack_user_identity__cached_slack_login", "^slack_user_identity__cached_name", "^teams__name", + "=public_primary_key", ) filterset_class = UserFilter diff --git a/engine/apps/integrations/tests/test_views.py b/engine/apps/integrations/tests/test_views.py index d6545c3d..80274a62 100644 --- a/engine/apps/integrations/tests/test_views.py +++ b/engine/apps/integrations/tests/test_views.py @@ -2,11 +2,24 @@ from unittest.mock import call, patch import pytest from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import OperationalError from django.urls import reverse +from pytest_django.plugin import _DatabaseBlocker from rest_framework import status from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel +from apps.integrations.mixins import AlertChannelDefiningMixin + + +class DatabaseBlocker(_DatabaseBlocker): + """Customize pytest_django db blocker to raise OperationalError exception.""" + + def _blocking_wrapper(*args, **kwargs): + __tracebackhide__ = True + __tracebackhide__ # Silence pyflakes + # mimic DB unavailable error + raise OperationalError("Database access disabled") @pytest.mark.django_db @@ -184,3 +197,99 @@ def test_integration_universal_endpoint_not_allow_files( assert response.status_code == status.HTTP_400_BAD_REQUEST assert not mock_create_alert.apply_async.called + + +@patch("apps.integrations.views.create_alert") +@pytest.mark.parametrize( + "integration_type", + [ + arc_type + for arc_type in AlertReceiveChannel.INTEGRATION_TYPES + if arc_type not in ["amazon_sns", "grafana", "alertmanager", "grafana_alerting", "maintenance"] + ], +) +@pytest.mark.django_db +def test_integration_universal_endpoint_works_without_db( + mock_create_alert, make_organization_and_user, make_alert_receive_channel, integration_type +): + organization, user = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel( + organization=organization, + author=user, + integration=integration_type, + ) + + client = APIClient() + url = reverse( + "integrations:universal", + kwargs={"integration_type": integration_type, "alert_channel_key": alert_receive_channel.token}, + ) + + # populate cache + AlertChannelDefiningMixin().update_alert_receive_channel_cache() + + # disable DB access + with DatabaseBlocker().block(): + data = {"foo": "bar"} + response = client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + + mock_create_alert.apply_async.assert_called_once_with( + [], + { + "title": None, + "message": None, + "image_url": None, + "link_to_upstream_details": None, + "alert_receive_channel_pk": alert_receive_channel.pk, + "integration_unique_data": None, + "raw_request_data": data, + }, + ) + + +@patch("apps.integrations.views.create_alertmanager_alerts") +@pytest.mark.django_db +def test_integration_grafana_endpoint_without_db_has_alerts( + mock_create_alertmanager_alerts, settings, make_organization_and_user, make_alert_receive_channel +): + settings.DEBUG = False + + integration_type = "grafana" + organization, user = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel( + organization=organization, + author=user, + integration=integration_type, + ) + + client = APIClient() + url = reverse("integrations:grafana", kwargs={"alert_channel_key": alert_receive_channel.token}) + + data = { + "alerts": [ + { + "foo": 123, + }, + { + "foo": 456, + }, + ] + } + + # populate cache + AlertChannelDefiningMixin().update_alert_receive_channel_cache() + + # disable DB access + with DatabaseBlocker().block(): + response = client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + + mock_create_alertmanager_alerts.apply_async.assert_has_calls( + [ + call((alert_receive_channel.pk, data["alerts"][0])), + call((alert_receive_channel.pk, data["alerts"][1])), + ] + ) diff --git a/engine/apps/labels/tests/factories.py b/engine/apps/labels/tests/factories.py index 094fe0d3..5f910989 100644 --- a/engine/apps/labels/tests/factories.py +++ b/engine/apps/labels/tests/factories.py @@ -10,7 +10,7 @@ from common.utils import UniqueFaker class LabelKeyFactory(factory.DjangoModelFactory): - id = UniqueFaker("sentence", nb_words=3) + id = UniqueFaker("pystr", max_chars=36) name = UniqueFaker("sentence", nb_words=3) class Meta: @@ -18,7 +18,7 @@ class LabelKeyFactory(factory.DjangoModelFactory): class LabelValueFactory(factory.DjangoModelFactory): - id = UniqueFaker("sentence", nb_words=3) + id = UniqueFaker("pystr", max_chars=36) name = UniqueFaker("sentence", nb_words=3) class Meta: diff --git a/engine/apps/public_api/serializers/users.py b/engine/apps/public_api/serializers/users.py index 6b0f8f26..4c3df2f4 100644 --- a/engine/apps/public_api/serializers/users.py +++ b/engine/apps/public_api/serializers/users.py @@ -53,7 +53,8 @@ class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin): class Meta: model = User - fields = ["id", "email", "slack", "username", "role", "is_phone_number_verified"] + fields = ["id", "email", "slack", "username", "role", "is_phone_number_verified", "timezone"] + read_only_fields = ["timezone"] @staticmethod def get_role(obj): diff --git a/engine/apps/public_api/tests/test_users.py b/engine/apps/public_api/tests/test_users.py index fc77fcd4..433ac485 100644 --- a/engine/apps/public_api/tests/test_users.py +++ b/engine/apps/public_api/tests/test_users.py @@ -34,6 +34,7 @@ def test_get_user( "username": user.username, "role": "admin", "is_phone_number_verified": False, + "timezone": user.timezone, } assert response.status_code == status.HTTP_200_OK @@ -72,6 +73,7 @@ def test_get_users_list( "username": user_1.username, "role": "admin", "is_phone_number_verified": False, + "timezone": user_1.timezone, }, { "id": user_2.public_primary_key, @@ -80,6 +82,7 @@ def test_get_users_list( "username": user_2.username, "role": "admin", "is_phone_number_verified": False, + "timezone": user_2.timezone, }, ], "current_page_number": 1, diff --git a/engine/apps/social_auth/pipeline.py b/engine/apps/social_auth/pipeline.py index 10acb47d..6eb6c621 100644 --- a/engine/apps/social_auth/pipeline.py +++ b/engine/apps/social_auth/pipeline.py @@ -57,6 +57,11 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args strategy.session[REDIRECT_FIELD_NAME] = url return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + # at this point everything is correct and we can create the SlackUserIdentity + # be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want + # to be sure to clear these so they do not see them again + strategy.session.flush() + slack_user_identity, _ = SlackUserIdentity.objects.get_or_create( slack_id=slack_user_id, slack_team_identity=slack_team_identity, diff --git a/engine/apps/user_management/middlewares.py b/engine/apps/user_management/middlewares.py index d9b65d08..10d5df1a 100644 --- a/engine/apps/user_management/middlewares.py +++ b/engine/apps/user_management/middlewares.py @@ -32,6 +32,11 @@ class OrganizationMovedMiddleware(MiddlewareMixin): if (v := request.META.get("HTTP_AUTHORIZATION", None)) is not None: headers["Authorization"] = v + if "amazon_sns" in request.path: + for k, v in request.META.items(): + if k.startswith("x-amz-sns-"): + headers[k] = v + response = self.make_request(request.method, url, headers, request.body) return HttpResponse(response.content, status=response.status_code) diff --git a/engine/apps/user_management/tests/test_region.py b/engine/apps/user_management/tests/test_region.py index 6331327c..bf6d640e 100644 --- a/engine/apps/user_management/tests/test_region.py +++ b/engine/apps/user_management/tests/test_region.py @@ -238,3 +238,36 @@ def test_user_schedule_export_token_raises_exception_organization_moved( assert False except OrganizationMovedException as e: assert e.organization == organization + + +@patch("apps.user_management.middlewares.OrganizationMovedMiddleware.make_request") +@pytest.mark.django_db +def test_organization_moved_middleware_amazon_sns_headers( + mocked_make_request, make_organization_and_region, make_alert_receive_channel +): + organization, region = make_organization_and_region() + organization.save() + + alert_receive_channel = make_alert_receive_channel( + organization=organization, + integration="amazon_sns", + ) + + expected_sns_headers = { + "x-amz-sns-subscription-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test:3aab6edb-0c5e-4fa9-b876-64409d1f6c63", + "x-amz-sns-topic-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test", + "x-amz-sns-message-id": "473efe1d-8ea4-5252-8124-a3d5ff7408c5", + "x-amz-sns-message-type": "Notification", + } + expected_message = bytes(f"Redirected to {region.oncall_backend_url}", "utf-8") + mocked_make_request.return_value = HttpResponse(expected_message, status=status.HTTP_200_OK) + + client = APIClient() + url = reverse("integrations:amazon_sns", kwargs={"alert_channel_key": alert_receive_channel.token}) + + data = {"value": "test"} + response = client.post(url, data, format="json", **expected_sns_headers) + assert mocked_make_request.called + assert expected_sns_headers.items() <= mocked_make_request.call_args.args[2].items() + assert response.content == expected_message + assert response.status_code == status.HTTP_200_OK diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 92831e8c..23cbc3ab 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { 'newlines-between': 'always', }, ], + 'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-unused-vars': [ 'warn', { diff --git a/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts b/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts index 56702636..202f1832 100644 --- a/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts +++ b/grafana-plugin/e2e-tests/integrations/heartbeat.test.ts @@ -15,7 +15,7 @@ test.describe("updating an integration's heartbeat interval works", async () => }; test('change heartbeat interval', async ({ adminRolePage: { page } }) => { - await createIntegration(page, generateRandomValue()); + await createIntegration({ page, integrationName: generateRandomValue() }); await _openHeartbeatSettingsForm(page); @@ -43,7 +43,7 @@ test.describe("updating an integration's heartbeat interval works", async () => }); test('send heartbeat', async ({ adminRolePage: { page } }) => { - await createIntegration(page, generateRandomValue()); + await createIntegration({ page, integrationName: generateRandomValue() }); await _openHeartbeatSettingsForm(page); diff --git a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts new file mode 100644 index 00000000..b2ad52ad --- /dev/null +++ b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../fixtures'; +import { generateRandomValue } from '../utils/forms'; +import { createIntegration } from '../utils/integrations'; + +test('Integrations table shows data in Connections and Direct Paging tabs', async ({ adminRolePage: { page } }) => { + // // Create 2 integrations that are not Direct Paging + const ID = generateRandomValue(); + const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`; + const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`; + const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging`; + + await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME }); + await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + + await createIntegration({ + page, + integrationSearchText: 'Alertmanager', + shouldGoToIntegrationsPage: false, + integrationName: ALERTMANAGER_INTEGRATION_NAME, + }); + await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + + // Create 1 Direct Paging integration if it doesn't exist + const integrationsTable = page.getByTestId('integrations-table'); + await page.getByRole('tab', { name: 'Tab Direct Paging' }).click(); + const isDirectPagingAlreadyCreated = await page.getByText('Direct paging').isVisible(); + if (!isDirectPagingAlreadyCreated) { + await createIntegration({ + page, + integrationSearchText: 'Direct paging', + shouldGoToIntegrationsPage: false, + integrationName: DIRECT_PAGING_INTEGRATION_NAME, + }); + } + await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + + // By default Connections tab is opened and newly created integrations are visible except Direct Paging one + await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).toBeVisible(); + await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).toBeVisible(); + await expect(integrationsTable).not.toContainText(DIRECT_PAGING_INTEGRATION_NAME); + + // Then after switching to Direct Paging tab only Direct Paging integration is visible + await page.getByRole('tab', { name: 'Tab Direct Paging' }).click(); + await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).not.toBeVisible(); + await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).not.toBeVisible(); + await expect(integrationsTable).toContainText(DIRECT_PAGING_INTEGRATION_NAME); +}); diff --git a/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts b/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts index d4066a93..88ff3c6e 100644 --- a/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts +++ b/grafana-plugin/e2e-tests/integrations/maintenanceMode.test.ts @@ -106,7 +106,7 @@ test.describe('maintenance mode works', () => { const integrationName = generateRandomValue(); await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName); - await createIntegration(page, integrationName); + await createIntegration({ page, integrationName }); await assignEscalationChainToIntegration(page, escalationChainName); await enableMaintenanceMode(page, maintenanceModeType); diff --git a/grafana-plugin/e2e-tests/utils/escalationChain.ts b/grafana-plugin/e2e-tests/utils/escalationChain.ts index c24c5afb..74115d43 100644 --- a/grafana-plugin/e2e-tests/utils/escalationChain.ts +++ b/grafana-plugin/e2e-tests/utils/escalationChain.ts @@ -32,7 +32,7 @@ export const createEscalationChain = async ( await page.locator('text=Loading...').waitFor({ state: 'detached' }); // open the create escalation chain modal - (await page.waitForSelector('text=New Escalation Chain')).click(); + (await page.waitForSelector('text=/New Escalation Chain/i')).click(); // fill in the name input await fillInInput(page, 'div[data-testid="create-escalation-chain-name-input-modal"] >> input', escalationChainName); @@ -44,7 +44,10 @@ export const createEscalationChain = async ( if (escalationStep) { // add an escalation step await selectDropdownValue({ - page, selectType: 'grafanaSelect', placeholderText: 'Add escalation step...', value: escalationStep, + page, + selectType: 'grafanaSelect', + placeholderText: 'Add escalation step...', + value: escalationStep, }); // toggle important @@ -52,13 +55,15 @@ export const createEscalationChain = async ( await selectDropdownValue({ page, selectType: 'grafanaSelect', - placeholderText: "Default", - value: "Important", + placeholderText: 'Default', + value: 'Important', }); } // select the escalation step value (e.g. user or schedule) - if (escalationStepValue) {await selectEscalationStepValue(page, escalationStep, escalationStepValue);} + if (escalationStepValue) { + await selectEscalationStepValue(page, escalationStep, escalationStepValue); + } } }; diff --git a/grafana-plugin/e2e-tests/utils/integrations.ts b/grafana-plugin/e2e-tests/utils/integrations.ts index 8c91598e..4a45211f 100644 --- a/grafana-plugin/e2e-tests/utils/integrations.ts +++ b/grafana-plugin/e2e-tests/utils/integrations.ts @@ -1,25 +1,40 @@ import { Page } from '@playwright/test'; -import { clickButton, selectDropdownValue } from './forms'; +import { clickButton, generateRandomValue, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; -const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]'; - export const openCreateIntegrationModal = async (page: Page): Promise => { - // go to the integrations page - await goToOnCallPage(page, 'integrations'); - // open the create integration modal - (await page.waitForSelector('text=New integration')).click(); + await page.getByRole('button', { name: 'New integration' }).click(); // wait for it to pop up - await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR); + await page.getByTestId('create-integration-modal').waitFor(); }; -export const createIntegration = async (page: Page, integrationName: string): Promise => { +export const createIntegration = async ({ + page, + integrationName = `integration-${generateRandomValue()}`, + integrationSearchText = 'Webhook', + shouldGoToIntegrationsPage = true, +}: { + page: Page; + integrationName?: string; + integrationSearchText?: string; + shouldGoToIntegrationsPage?: boolean; +}): Promise => { + if (shouldGoToIntegrationsPage) { + // go to the integrations page + await goToOnCallPage(page, 'integrations'); + } + await openCreateIntegrationModal(page); - // create a webhook integration - (await page.waitForSelector(`${CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR} >> text=Webhook`)).click(); + // create an integration + await page + .getByTestId('create-integration-modal') + .getByTestId('integration-display-name') + .filter({ hasText: integrationSearchText }) + .first() + .click(); // fill in the required inputs (await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName); @@ -55,7 +70,7 @@ export const createIntegrationAndSendDemoAlert = async ( integrationName: string, escalationChainName: string ): Promise => { - await createIntegration(page, integrationName); + await createIntegration({ page, integrationName }); await assignEscalationChainToIntegration(page, escalationChainName); await sendDemoAlert(page); }; diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index e3b7d1c1..fb8278d3 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -13,6 +13,8 @@ "test:silent": "jest --silent", "test:e2e": "yarn playwright test --grep-invert @expensive", "test:e2e-expensive": "yarn playwright test --grep @expensive", + "test:e2e:watch": "yarn test:e2e --ui", + "test:e2e:gen": "yarn playwright codegen http://localhost:3000", "cleanup-e2e-results": "rm -rf playwright-report && rm -rf test-results", "e2e-show-report": "yarn playwright show-report", "dev": "grafana-toolkit plugin:dev", @@ -126,13 +128,13 @@ "mobx-react": "6.1.1", "object-hash": "^3.0.0", "prettier": "^2.8.2", + "qrcode.react": "^3.1.0", "raw-loader": "^4.0.2", "rc-table": "^7.17.1", "react-copy-to-clipboard": "^5.0.2", "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", "react-modal": "^3.15.1", - "react-qr-code": "^2.0.8", "react-responsive": "^8.1.0", "react-router-dom": "5.3.3", "react-sortable-hoc": "^1.11.0", diff --git a/grafana-plugin/src/assets/style/vars.css b/grafana-plugin/src/assets/style/vars.css index c10443b3..17585703 100644 --- a/grafana-plugin/src/assets/style/vars.css +++ b/grafana-plugin/src/assets/style/vars.css @@ -71,7 +71,7 @@ --working-hours-shades-color-light: rgba(17, 18, 23, 0.04); } -.theme-dark { +.theme-dark:not(.theme-light) { --cards-background: var(--gray-9); --highlighted-row-bg: var(--gray-9); --disabled-button-color: hsla(0, 0%, 100%, 0.08); diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss index 7b5f57ac..cfbe02c8 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss @@ -1,4 +1,6 @@ .add-responders-dropdown { + max-height: 500px; + overflow: hidden; border: var(--border-medium); position: absolute; right: 16px; @@ -8,7 +10,7 @@ z-index: 10; } -.team-direct-paging-info-alert { +.info-alert { margin: 8px; } diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx index 6e256c39..e1aea823 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx @@ -10,6 +10,7 @@ import GTable from 'components/GTable/GTable'; import Text from 'components/Text/Text'; import { Alert as AlertType } from 'models/alertgroup/alertgroup.types'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { PaginatedUsersResponse } from 'models/user/user'; import { UserCurrentlyOnCall } from 'models/user/user.types'; import { useStore } from 'state/useStore'; import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks'; @@ -51,12 +52,11 @@ const AddRespondersPopup = observer( const [searchLoading, setSearchLoading] = useState(true); const [activeOption, setActiveOption] = useState(isCreateMode ? TabOptions.Teams : TabOptions.Users); const [teamSearchResults, setTeamSearchResults] = useState([]); - const [userSearchResults, setUserSearchResults] = useState([]); + const [onCallUserSearchResults, setOnCallUserSearchResults] = useState([]); + const [notOnCallUserSearchResults, setNotOnCallUserSearchResults] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const ref = useRef(); - const usersCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => is_currently_oncall); - const usersNotCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => !is_currently_oncall); useOnClickOutside(ref, () => { setVisible(false); @@ -97,11 +97,18 @@ const AddRespondersPopup = observer( ); const searchForUsers = useCallback(async () => { - /** - * specifying is_currently_oncall=all will tell the backend not to paginate the results - */ - const userResults = await userStore.search({ searchTerm, is_currently_oncall: 'all' }); - setUserSearchResults(userResults); + const _search = async (is_currently_oncall: boolean) => { + const response = await userStore.search>({ + searchTerm, + is_currently_oncall, + }); + return response.results; + }; + + const [onCallUserSearchResults, notOnCallUserSearchResults] = await Promise.all([_search(true), _search(false)]); + + setOnCallUserSearchResults(onCallUserSearchResults); + setNotOnCallUserSearchResults(notOnCallUserSearchResults); }, [searchTerm]); const searchForTeams = useCallback(async () => { @@ -153,9 +160,12 @@ const AddRespondersPopup = observer( useEffect(() => { if (existingPagedUsers.length > 0) { const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk); - setUserSearchResults((userSearchResults) => - userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk)) - ); + + const _filterUsers = (users: UserCurrentlyOnCall[]) => + users.filter(({ pk }) => !existingPagedUserIds.includes(pk)); + + setOnCallUserSearchResults(_filterUsers); + setNotOnCallUserSearchResults(_filterUsers); } }, [existingPagedUsers]); @@ -293,7 +303,7 @@ const AddRespondersPopup = observer( ) : ( <> - - + + We display a maximum of 100 users per category. Use the search bar above to refine results. You + can search by username, email, or team name. + + ) as any + } + /> + +
+ +
)} diff --git a/grafana-plugin/src/containers/Labels/LabelsFilter.tsx b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx index 5c099987..667630c9 100644 --- a/grafana-plugin/src/containers/Labels/LabelsFilter.tsx +++ b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx @@ -21,11 +21,8 @@ interface LabelsFilterProps { const LabelsFilter = observer((props: LabelsFilterProps) => { const { filterType, className, autoFocus, value: propsValue, onChange } = props; - const [value, setValue] = useState([]); - const [keys, setKeys] = useState([]); - const { alertGroupStore, labelsStore } = useStore(); const loadKeys = @@ -44,9 +41,7 @@ const LabelsFilter = observer((props: LabelsFilterProps) => { useEffect(() => { const keyValuePairs = (propsValue || []).map((k) => k.split(':')); - const promises = keyValuePairs.map(([keyId]) => loadValuesForKey(keyId)); - const fetchKeyValues = async () => await Promise.all(promises); fetchKeyValues().then((list) => { diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 4e87938f..9518a2b8 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -152,7 +152,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => { App connected - You can sync one application to your account. To setup new device please disconnect app first. + You can only sync one application to your account. To setup a new device, please disconnect the currently + connected device first.
@@ -168,7 +169,9 @@ const MobileAppConnection = observer(({ userPk }: Props) => { Sign In - Open Grafana IRM mobile application and scan this code to sync it with your account. + + Open the Grafana OnCall mobile application and scan this code to sync it with your account. +
{isQRBlurry && } diff --git a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap index 1f46a2a3..74af36d7 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -120,7 +120,7 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche - Open Grafana IRM mobile application and scan this code to sync it with your account. + Open the Grafana OnCall mobile application and scan this code to sync it with your account.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -2855,7 +660,7 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c - You can sync one application to your account. To setup new device please disconnect app first. + You can only sync one application to your account. To setup a new device, please disconnect the currently connected device first.
= (props: Props) => { return ( - + ); }; diff --git a/grafana-plugin/src/containers/MobileAppConnection/parts/QRCode/__snapshots__/QRCode.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/parts/QRCode/__snapshots__/QRCode.test.tsx.snap index 511496e9..8fa9a305 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/parts/QRCode/__snapshots__/QRCode.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/parts/QRCode/__snapshots__/QRCode.test.tsx.snap @@ -7,2213 +7,18 @@ exports[`QRCode it renders properly 1`] = ` > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index 936ec10c..13be43ab 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -44,6 +44,7 @@ interface RemoteFiltersProps extends WithStoreProps { defaultFilters?: FiltersValues; extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode; grafanaTeamStore: GrafanaTeamStore; + skipFilterOptionFn?: (filterOption: FilterOption) => boolean; } interface RemoteFiltersState { filterOptions?: FilterOption[]; @@ -86,11 +87,16 @@ class RemoteFilters extends Component { page, store: { filtersStore }, defaultFilters, + skipFilterOptionFn, } = this.props; - const filterOptions = await filtersStore.updateOptionsForPage(page); + let filterOptions = await filtersStore.updateOptionsForPage(page); const currentTablePageNum = parseInt(filtersStore.currentTablePageNum[page] || query.p || 1, 10); + if (skipFilterOptionFn) { + filterOptions = filterOptions.filter((option: FilterOption) => !skipFilterOptionFn(option)); + } + // set the current page from filters/query or default it to 1 filtersStore.setCurrentTablePageNum(page, currentTablePageNum); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx index b52c39e1..f7ed288d 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/SlackConnector.tsx @@ -47,7 +47,7 @@ const SlackConnector = (props: SlackConnectorProps) => { diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx index d6d72f0b..ae11270f 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -import { Button, VerticalGroup, Icon } from '@grafana/ui'; +import { Button, VerticalGroup, Icon, HorizontalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import Block from 'components/GBlock/Block'; @@ -48,7 +48,9 @@ export const SlackTab = () => { 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 67afa9e6..972dccb6 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 @@ -21,6 +21,7 @@ import { AlertReceiveChannelCounters, ContactPoint, MaintenanceMode, + SupportedIntegrationFilters, } from './alert_receive_channel.types'; export class AlertReceiveChannelStore extends BaseStore { @@ -132,8 +133,17 @@ export class AlertReceiveChannelStore extends BaseStore { return results; } - async updatePaginatedItems(query: any = '', page = 1, updateCounters = false, invalidateFn = undefined) { - const filters = typeof query === 'string' ? { search: query } : query; + async updatePaginatedItems({ + filters, + page = 1, + updateCounters = false, + invalidateFn = undefined, + }: { + filters: SupportedIntegrationFilters; + page: number; + updateCounters: boolean; + invalidateFn: () => boolean; + }) { const { count, results, page_size } = await makeRequest(this.path, { params: { ...filters, page } }); if (invalidateFn?.()) { diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index 81c1940f..ebb2fbba 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -62,3 +62,11 @@ export interface ContactPoint { contactPoint: string; notificationConnected: boolean; } + +export interface SupportedIntegrationFilters { + integration?: string[]; + integration_ne?: string[]; + team?: string[]; + label?: string[]; + searchTerm?: string; +} diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 1c658077..a3d10505 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -15,7 +15,7 @@ import { isUserActionAllowed, UserActions } from 'utils/authorization'; import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; -type PaginatedUsersResponse = { +export type PaginatedUsersResponse = { count: number; page_size: number; results: UT[]; diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 6d3caae3..06a60c2d 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -34,6 +34,7 @@ export interface User extends BaseUser { unverified_phone_number?: string; slack_user_identity: { avatar: string; + display_name: string; name: string; slack_id: string; slack_login: string; diff --git a/grafana-plugin/src/pages/integrations/Integrations.module.scss b/grafana-plugin/src/pages/integrations/Integrations.module.scss index a2d942f2..69f57cad 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.module.scss +++ b/grafana-plugin/src/pages/integrations/Integrations.module.scss @@ -7,6 +7,10 @@ width: 40px; } +.tabsBar { + margin-bottom: 24px; +} + .integrations-header { margin-bottom: 24px; right: 0; @@ -45,3 +49,7 @@ background: var(--cards-background); } } + +.goToDirectPagingAlert { + margin-top: 24px; +} diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index d67af530..205d4eeb 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -1,7 +1,18 @@ import React from 'react'; import { LabelTag } from '@grafana/labels'; -import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui'; +import { + HorizontalGroup, + Button, + VerticalGroup, + Icon, + ConfirmModal, + Tooltip, + Tab, + TabsBar, + TabContent, + Alert, +} from '@grafana/ui'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; @@ -28,7 +39,11 @@ import TeamName from 'containers/TeamName/TeamName'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { HeartIcon, HeartRedIcon } from 'icons'; import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; -import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types'; +import { + AlertReceiveChannel, + MaintenanceMode, + SupportedIntegrationFilters, +} from 'models/alert_receive_channel/alert_receive_channel.types'; import { LabelKeyValue } from 'models/label/label.types'; import IntegrationHelper from 'pages/integration/Integration.helper'; import { AppFeature } from 'state/features'; @@ -41,11 +56,29 @@ import { PAGE, TEXT_ELLIPSIS_CLASS } from 'utils/consts'; import styles from './Integrations.module.scss'; +enum TabType { + Connections = 'connections', + DirectPaging = 'direct-paging', +} + +const TAB_QUERY_PARAM_KEY = 'tab'; + +const TABS = [ + { + label: 'Connections', + value: TabType.Connections, + }, + { + label: 'Direct Paging', + value: TabType.DirectPaging, + }, +]; + const cx = cn.bind(styles); const FILTERS_DEBOUNCE_MS = 500; interface IntegrationsState extends PageBaseState { - integrationsFilters: Record; + integrationsFilters: SupportedIntegrationFilters; alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new'; confirmationModal: { isOpen: boolean; @@ -57,6 +90,7 @@ interface IntegrationsState extends PageBaseState { confirmationText?: string; onConfirm: () => void; }; + activeTab: TabType; } interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {} @@ -67,9 +101,10 @@ class Integrations extends React.Component super(props); this.state = { - integrationsFilters: { searchTerm: '' }, + integrationsFilters: { searchTerm: '', integration_ne: ['direct_paging'] }, errorData: initErrorDataState(), confirmationModal: undefined, + activeTab: props.query[TAB_QUERY_PARAM_KEY] || TabType.Connections, }; } @@ -81,14 +116,12 @@ class Integrations extends React.Component if (prevProps.match.params.id !== this.props.match.params.id) { this.parseQueryParams(); } + if (prevProps.query[TAB_QUERY_PARAM_KEY] !== this.props.query[TAB_QUERY_PARAM_KEY]) { + this.onTabChange(this.props.query[TAB_QUERY_PARAM_KEY] as TabType); + } } parseQueryParams = async () => { - this.setState((_prevState) => ({ - errorData: initErrorDataState(), - alertReceiveChannelId: undefined, - })); // reset state on query parse - const { store, match: { @@ -96,6 +129,11 @@ class Integrations extends React.Component }, } = this.props; + this.setState((_prevState) => ({ + errorData: initErrorDataState(), + alertReceiveChannelId: undefined, + })); // reset state on query parse + if (!id) { return; } @@ -114,24 +152,52 @@ class Integrations extends React.Component } }; + getFiltersBasedOnCurrentTab = () => ({ + ...this.state.integrationsFilters, + ...(this.state.activeTab === TabType.DirectPaging + ? { integration: ['direct_paging'] } + : { + integration_ne: ['direct_paging'], + integration: this.state.integrationsFilters.integration?.filter( + (integration) => integration !== 'direct_paging' + ), + }), + }); + update = () => { const { store } = this.props; - const { integrationsFilters } = this.state; const page = store.filtersStore.currentTablePageNum[PAGE.Integrations]; LocationHelper.update({ p: page }, 'partial'); - return store.alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page, false, () => - this.invalidateRequestFn(page) + return store.alertReceiveChannelStore.updatePaginatedItems({ + filters: this.getFiltersBasedOnCurrentTab(), + page, + updateCounters: false, + invalidateFn: () => this.invalidateRequestFn(page), + }); + }; + + onTabChange = (tab: TabType) => { + LocationHelper.update({ tab, integration: undefined, search: undefined }, 'partial'); + this.setState( + { + activeTab: tab, + }, + () => { + this.handleChangePage(1); + } ); }; render() { const { store, query } = this.props; - const { alertReceiveChannelId, confirmationModal } = this.state; + const { alertReceiveChannelId, confirmationModal, activeTab, integrationsFilters } = this.state; const { alertReceiveChannelStore } = store; const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult(); + const isDirectPagingSelectedOnConnectionsTab = + activeTab === TabType.Connections && integrationsFilters.integration?.includes('direct_paging'); return ( <> @@ -158,27 +224,58 @@ class Integrations extends React.Component
- - + + {TABS.map(({ label, value }) => ( + this.onTabChange(value)} + /> + ))} + + + name === 'integration', + })} + /> + {isDirectPagingSelectedOnConnectionsTab && ( + + + They are in a separate tab now. Go to{' '} + + Direct Paging tab + {' '} + to view them. + + + )} + +
@@ -455,6 +552,7 @@ class Integrations extends React.Component getTableColumns = (hasFeatureFn) => { const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store; + const isConnectionsTab = this.state.activeTab === TabType.Connections; const columns = [ { @@ -476,21 +574,24 @@ class Integrations extends React.Component key: 'datasource', render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore), }, + ...(isConnectionsTab + ? [ + { + width: '10%', + title: 'Maintenance', + key: 'maintenance', + render: (item: AlertReceiveChannel) => this.renderMaintenance(item), + }, + { + width: '5%', + title: 'Heartbeat', + key: 'heartbeat', + render: (item: AlertReceiveChannel) => this.renderHeartbeat(item), + }, + ] + : []), { - width: '10%', - title: 'Maintenance', - key: 'maintenance', - render: (item: AlertReceiveChannel) => this.renderMaintenance(item), - }, - { - width: '5%', - title: 'Heartbeat', - key: 'heartbeat', - render: (item: AlertReceiveChannel) => this.renderHeartbeat(item), - }, - - { - width: '15%', + width: isConnectionsTab ? '15%' : '30%', title: 'Team', render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items), }, @@ -572,12 +673,15 @@ class Integrations extends React.Component applyFilters = async (isOnMount: boolean) => { const { store } = this.props; const { alertReceiveChannelStore } = store; - const { integrationsFilters } = this.state; - const newPage = isOnMount ? store.filtersStore.currentTablePageNum[PAGE.Integrations] : 1; return alertReceiveChannelStore - .updatePaginatedItems(integrationsFilters, newPage, false, () => this.invalidateRequestFn(newPage)) + .updatePaginatedItems({ + filters: this.getFiltersBasedOnCurrentTab(), + page: newPage, + updateCounters: false, + invalidateFn: () => this.invalidateRequestFn(newPage), + }) .then(() => { store.filtersStore.currentTablePageNum[PAGE.Integrations] = newPage; LocationHelper.update({ p: newPage }, 'partial'); diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx index bac6f633..e0540ec6 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx @@ -289,7 +289,9 @@ class SlackSettings extends Component { ) : ( {store.hasFeature(AppFeature.LiveSettings) && ( diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 77737039..f6b902f3 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -12475,10 +12475,10 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qr.js@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" - integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== +qrcode.react@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8" + integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q== query-string@*: version "7.1.1" @@ -13224,14 +13224,6 @@ react-popper@2.3.0, react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" -react-qr-code@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.8.tgz#d34a766fb5b664a40dbdc7020f7ac801bacb2851" - integrity sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA== - dependencies: - prop-types "^15.8.1" - qr.js "0.0.0" - react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"