diff --git a/docs/sources/manage/notify/slack/index.md b/docs/sources/manage/notify/slack/index.md index 969cf36ba0..1e9781e7cb 100644 --- a/docs/sources/manage/notify/slack/index.md +++ b/docs/sources/manage/notify/slack/index.md @@ -122,6 +122,8 @@ This set of permissions is supporting the ability of Grafana OnCall to match use (deprecated) slack commands. - **Create and manage user groups** β€” the permission is used to automatically update user groups linked to on-call schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends. + - **NOTE**: per [Slack's documentation](https://slack.com/help/articles/212906697-Create-a-user-group), you must have + a paid plan for this feature to work properly - **Set presence for Grafana OnCall** ## Post-install configuration for Slack integration diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index 353c9edc5c..e8a892b0c3 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alertgroups/ title: Alert groups HTTP API weight: 400 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Alert groups HTTP API @@ -48,6 +54,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + These available filter parameters should be provided as `GET` arguments: - `id` diff --git a/docs/sources/oncall-api-reference/alerts.md b/docs/sources/oncall-api-reference/alerts.md index df1c3d6539..ca2ba1b416 100644 --- a/docs/sources/oncall-api-reference/alerts.md +++ b/docs/sources/oncall-api-reference/alerts.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alerts/ title: Alerts HTTP API weight: 100 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Alerts HTTP API @@ -105,6 +111,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameters should be provided as `GET` arguments: - `id` diff --git a/docs/sources/oncall-api-reference/escalation_chains.md b/docs/sources/oncall-api-reference/escalation_chains.md index 6c57470032..c79f21cf6c 100644 --- a/docs/sources/oncall-api-reference/escalation_chains.md +++ b/docs/sources/oncall-api-reference/escalation_chains.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/ title: Escalation chains HTTP API weight: 200 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Escalation chains HTTP API @@ -89,6 +95,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + **HTTP request** `GET {{API_URL}}/api/v1/escalation_chains/` diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index 3c4b419c71..1b123895a0 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/ title: Escalation policies HTTP API weight: 300 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Escalation policies HTTP API @@ -144,6 +150,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameter should be provided as a `GET` argument: - `escalation_chain_id` diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index e2ba7cf98a..d1f2ed164c 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -8,6 +8,11 @@ refs: destination: /docs/oncall//configure/integrations/references/alertmanager/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/references/alertmanager/ + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Integrations HTTP API @@ -233,6 +238,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + **HTTP request** `GET {{API_URL}}/api/v1/integrations/` diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index 7c991d50ed..0ea61b3346 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/ title: OnCall shifts HTTP API weight: 600 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # OnCall shifts HTTP API @@ -150,6 +156,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameters should be provided as `GET` arguments: - `name` (Exact match) diff --git a/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index a02daeeabb..4c00b47842 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -13,6 +13,11 @@ refs: destination: /docs/oncall//configure/integrations/outgoing-webhooks/#event-types - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/outgoing-webhooks/#event-types + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Outgoing webhooks @@ -66,6 +71,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + ## Get webhook ```shell @@ -244,3 +251,5 @@ The above command returns JSON structured in the following way: "total_pages": 1 } ``` + +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. diff --git a/docs/sources/oncall-api-reference/personal_notification_rules.md b/docs/sources/oncall-api-reference/personal_notification_rules.md index e683e90b42..b568895703 100644 --- a/docs/sources/oncall-api-reference/personal_notification_rules.md +++ b/docs/sources/oncall-api-reference/personal_notification_rules.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/ title: Personal notification rules HTTP API weight: 800 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Personal notification rules HTTP API @@ -122,6 +128,8 @@ The above command returns JSON structured in the following ways: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameters should be provided as `GET` arguments: - `user_id` diff --git a/docs/sources/oncall-api-reference/resolution_notes.md b/docs/sources/oncall-api-reference/resolution_notes.md index 674c7d8576..d00c1d779b 100644 --- a/docs/sources/oncall-api-reference/resolution_notes.md +++ b/docs/sources/oncall-api-reference/resolution_notes.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/resolution_notes/ title: Resolution notes HTTP API weight: 900 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Resolution notes HTTP API @@ -99,6 +105,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameter should be provided as a `GET` argument: - `alert_group_id` diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index 1ecf5d4f04..87c3d01c60 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/routes/ title: Routes HTTP API weight: 1100 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Routes HTTP API @@ -134,6 +140,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameters should be provided as `GET` arguments: - `integration_id` diff --git a/docs/sources/oncall-api-reference/schedules.md b/docs/sources/oncall-api-reference/schedules.md index 5a2d80c86f..008a9809da 100644 --- a/docs/sources/oncall-api-reference/schedules.md +++ b/docs/sources/oncall-api-reference/schedules.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/ title: Schedules HTTP API weight: 1200 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Schedules HTTP API @@ -139,6 +145,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameter should be provided as a `GET` argument: - `name` (Exact match) @@ -307,6 +315,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + ### Caveats Some notes on the `start_date` and `end_date` query parameters: diff --git a/docs/sources/oncall-api-reference/shift_swaps.md b/docs/sources/oncall-api-reference/shift_swaps.md index 77ba958fc7..97a143e18c 100644 --- a/docs/sources/oncall-api-reference/shift_swaps.md +++ b/docs/sources/oncall-api-reference/shift_swaps.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/shift_swaps/ title: Shift swap requests HTTP API weight: 1200 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Shift swap requests HTTP API @@ -185,6 +191,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameters may be provided as a `GET` arguments: - `starting_after` (an ISO 8601 timestamp string, filter requests starting after the specified datetime) diff --git a/docs/sources/oncall-api-reference/slack_channels.md b/docs/sources/oncall-api-reference/slack_channels.md index 72036d4e32..dad890dc52 100644 --- a/docs/sources/oncall-api-reference/slack_channels.md +++ b/docs/sources/oncall-api-reference/slack_channels.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/ title: Slack channels HTTP API weight: 1300 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Slack channels HTTP API @@ -34,6 +40,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + The following available filter parameter should be provided as a `GET` argument: - `channel_name` diff --git a/docs/sources/oncall-api-reference/user_groups.md b/docs/sources/oncall-api-reference/user_groups.md index 9a45325195..da39320831 100644 --- a/docs/sources/oncall-api-reference/user_groups.md +++ b/docs/sources/oncall-api-reference/user_groups.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/ title: OnCall user groups HTTP API weight: 1400 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- @@ -41,6 +47,8 @@ The above command returns JSON structured in the following way: } ``` +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + | Parameter | Unique | Description | | --------- | :----: | :---------------------------------------------------------------------------------------------------- | | `id` | Yes | User Group ID | diff --git a/docs/sources/oncall-api-reference/users.md b/docs/sources/oncall-api-reference/users.md index 034b575e2d..1af09784d8 100644 --- a/docs/sources/oncall-api-reference/users.md +++ b/docs/sources/oncall-api-reference/users.md @@ -2,6 +2,12 @@ canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/ title: Grafana OnCall users HTTP API weight: 1500 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination --- # Grafana OnCall users HTTP API @@ -90,7 +96,7 @@ The above command returns JSON structured in the following way: } ``` -This endpoint retrieves all users. +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. The following available filter parameter should be provided as a `GET` argument: diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index acd98de81d..7e35597aaf 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -15,6 +15,7 @@ class Feature(enum.StrEnum): MSTEAMS = "msteams" SLACK = "slack" + UNIFIED_SLACK = "unified_slack" TELEGRAM = "telegram" LIVE_SETTINGS = "live_settings" GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" @@ -46,6 +47,9 @@ def _get_enabled_features(self, request): if settings.FEATURE_SLACK_INTEGRATION_ENABLED: enabled_features.append(Feature.SLACK) + if settings.UNIFIED_SLACK_APP_ENABLED: + enabled_features.append(Feature.UNIFIED_SLACK) + if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: enabled_features.append(Feature.TELEGRAM) diff --git a/engine/apps/google/client.py b/engine/apps/google/client.py index a0544ab339..bf7f981895 100644 --- a/engine/apps/google/client.py +++ b/engine/apps/google/client.py @@ -3,6 +3,7 @@ import typing from django.conf import settings +from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.errors import HttpError @@ -24,11 +25,24 @@ def __init__(self, event: GoogleCalendarEventType): self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc) -class GoogleCalendarHTTPError(Exception): +class _GoogleCalendarHTTPError(Exception): def __init__(self, http_error) -> None: self.error = http_error +class GoogleCalendarGenericHTTPError(_GoogleCalendarHTTPError): + """Raised when a generic HTTP error occurs when communicating with the Google Calendar API""" + + +class GoogleCalendarUnauthorizedHTTPError(_GoogleCalendarHTTPError): + """Raised when an HTTP 403 error occurs when communicating with the Google Calendar API""" + + +class GoogleCalendarRefreshError(Exception): + def __init__(self, refresh_error) -> None: + self.error = refresh_error + + class GoogleCalendarAPIClient: MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250 """ @@ -89,7 +103,27 @@ def fetch_out_of_office_events(self) -> typing.List[GoogleCalendarEvent]: .execute() ) except HttpError as e: - logger.error(f"GoogleCalendarAPIClient - Error fetching out of office events: {e}") - raise GoogleCalendarHTTPError(e) + if e.status_code == 403: + # this scenario can be encountered when, for some reason, the OAuth2 token that we have + # does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope + # example error: + # # noqa: E501 + logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}") + raise GoogleCalendarUnauthorizedHTTPError(e) + + logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}") + raise GoogleCalendarGenericHTTPError(e) + except RefreshError as e: + # TODO: come back and solve this properly once we get better logging output + # it seems like right now we are seeing RefreshError in two different scenarios: + # 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'}) + # 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'}) + # https://stackoverflow.com/a/49024030/3902555 + logger.error( + f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} " + # NOTE: remove e.args after debugging how to dig into the error details + f"args={e.args}" + ) + raise GoogleCalendarRefreshError(e) return [GoogleCalendarEvent(event) for event in events_result.get("items", [])] diff --git a/engine/apps/google/tasks.py b/engine/apps/google/tasks.py index 77b3d9677f..a1fa7ac507 100644 --- a/engine/apps/google/tasks.py +++ b/engine/apps/google/tasks.py @@ -3,7 +3,12 @@ from celery.utils.log import get_task_logger from apps.google import constants -from apps.google.client import GoogleCalendarAPIClient, GoogleCalendarHTTPError +from apps.google.client import ( + GoogleCalendarAPIClient, + GoogleCalendarGenericHTTPError, + GoogleCalendarRefreshError, + GoogleCalendarUnauthorizedHTTPError, +) from apps.google.models import GoogleOAuth2User from apps.schedules.models import OnCallSchedule, ShiftSwapRequest from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -33,8 +38,16 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N try: out_of_office_events = google_api_client.fetch_out_of_office_events() - except GoogleCalendarHTTPError: - logger.info(f"Failed to fetch out of office events for user {user_id}") + except GoogleCalendarUnauthorizedHTTPError: + # this happens because the user's access token is (somehow) missing the + # https://www.googleapis.com/auth/calendar.events.readonly scope + # they will need to reconnect their Google account and grant us the necessary scopes, retrying will not help + logger.exception(f"Failed to fetch out of office events for user {user_id} due to an unauthorized HTTP error") + # TODO: come back and solve this properly once we get better logging output + # user.reset_google_oauth2_settings() + return + except (GoogleCalendarRefreshError, GoogleCalendarGenericHTTPError): + logger.exception(f"Failed to fetch out of office events for user {user_id}") return for out_of_office_event in out_of_office_events: diff --git a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py index 69f1020b64..4ba50de263 100644 --- a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py +++ b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py @@ -125,7 +125,7 @@ def _test_setup(out_of_office_events): user_name = "Bob Smith" user = make_user_for_organization( organization, - # normally this πŸ‘‡ is done via User.finish_google_oauth2_connection_flow.. but since we're creating + # normally this πŸ‘‡ is done via User.save_google_oauth2_settings.. but since we're creating # the user via a fixture we need to manually add this google_calendar_settings={ "oncall_schedules_to_consider_for_shift_swaps": [], diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 73efeb26d2..3d54cd0d87 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -355,19 +355,43 @@ def get_instance_info( return data def get_instances(self, query: str, page_size=None): + MAX_RETRIES = 3 + if not page_size: page, _ = self.api_get(query) yield page else: + previous_cursor = None + retry_count = 0 cursor = 0 + while cursor is not None: - if query: - page_query = query + f"&cursor={cursor}&pageSize={page_size}" + previous_cursor = cursor + page, call_status = self.api_get(f"{query}&cursor={cursor}&pageSize={page_size}") + + if "nextCursor" in page: + cursor = page["nextCursor"] + yield page + elif retry_count == MAX_RETRIES: + break else: - page_query = f"?cursor={cursor}&pageSize={page_size}" - page, _ = self.api_get(page_query) - yield page - cursor = page["nextCursor"] + # nextCursor is missing from the response JSON, lets retry the request.. + # + # NOTE: this is here because there seems to be a bug in GCOM's API where when using cursor based + # pagination, the request is aborted on the GCOM side but still sends HTTP 200 w/ a partial + # JSON response. This was leading to KeyErrors when trying to read the nextCursor key. + # + # How the JSON is actually properly decoded is aside me πŸ€·β€β™‚οΈ, but for now lets simply retry the + # request if this scenario arises + # + # See this conversation for more context + # https://raintank-corp.slack.com/archives/C0K031RP1/p1723158123932529 + logger.warning( + f"GcomAPIClient.get_instances response was missing nextCursor key, likely a decoding error. " + f"http_response={page} call_status={call_status}" + ) + cursor = previous_cursor # retry the request using the previous nextCursor value + retry_count += 1 def _is_stack_in_certain_state(self, stack_id: str, state: str) -> bool: instance_info = self.get_instance_info(stack_id) diff --git a/engine/apps/grafana_plugin/tests/test_gcom_api_client.py b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py index 219396a25d..9a3f796e73 100644 --- a/engine/apps/grafana_plugin/tests/test_gcom_api_client.py +++ b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py @@ -1,5 +1,5 @@ import uuid -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -54,6 +54,80 @@ def test_get_instances_pagination(page_size, expected_pages, expected_items): assert items == expected_items +@patch("apps.grafana_plugin.helpers.client.APIClient.api_get") +def test_get_instances_pagination_handles_streaming_errors_with_cursor_pagination(mock_api_get): + query = GcomAPIClient.ACTIVE_INSTANCE_QUERY + page_size = 10 + next_cursor1 = "abcd1234" + next_cursor2 = "efgh5678" + instance1 = {"id": "1"} + instance2 = {"id": "2"} + instance3 = {"id": "3"} + + mock_api_get.side_effect = [ + ({"items": [instance1], "nextCursor": next_cursor1}, {}), + ({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key) + ({"items": [instance2], "nextCursor": next_cursor2}, {}), # retried second page request has nextCursor key + ({"items": [instance3], "nextCursor": None}, {}), # last page + ] + client = GcomAPIClient("someToken") + + objects = [] + for page in client.get_instances(query, page_size): + objects.extend(page["items"]) + + assert instance1 in objects + assert instance2 in objects + assert instance3 in objects + + mock_api_get.assert_has_calls( + [ + call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page + call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, first try + call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, retry + call(f"{query}&cursor={next_cursor2}&pageSize={page_size}"), # 3rd page + ] + ) + + +@patch("apps.grafana_plugin.helpers.client.APIClient.api_get") +def test_get_instances_pagination_doesnt_infinitely_retry_on_streaming_errors(mock_api_get): + query = GcomAPIClient.ACTIVE_INSTANCE_QUERY + page_size = 10 + next_cursor1 = "abcd1234" + instance1 = {"id": "1"} + instance2 = {"id": "2"} + + mock_api_get.side_effect = [ + ({"items": [instance1], "nextCursor": next_cursor1}, {}), + ({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key) + ({"items": [instance2]}, {}), # 2nd failed request for the second page + ({"items": [instance2]}, {}), # 3rd failed request for the second page + ({"items": [instance2]}, {}), # 4th failed request for the second page + ] + client = GcomAPIClient("someToken") + + objects = [] + for page in client.get_instances(query, page_size): + objects.extend(page["items"]) + + assert instance1 in objects + assert instance2 not in objects + + second_page_call = call(f"{query}&cursor={next_cursor1}&pageSize={page_size}") + + assert len(mock_api_get.mock_calls) == 5 + mock_api_get.assert_has_calls( + [ + call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page + second_page_call, # 2nd page, 1st try + second_page_call, # 2nd page, 1st retry + second_page_call, # 2nd page, 2nd retry + second_page_call, # 2nd page, 3rd retry + ] + ) + + @pytest.mark.parametrize( "query, expected_pages, expected_items", [ diff --git a/engine/apps/slack/errors.py b/engine/apps/slack/errors.py index bf7fa819f9..631bcffe72 100644 --- a/engine/apps/slack/errors.py +++ b/engine/apps/slack/errors.py @@ -56,6 +56,15 @@ class SlackAPIUsergroupNotFoundError(SlackAPIError): errors = ("no_such_subteam", "subteam_not_found") +class SlackAPIUsergroupPaidTeamOnlyError(SlackAPIError): + """ + https://api.slack.com/methods/usergroups.create#:~:text=Name%20too%20long.-,paid_teams_only,-Usergroups%20can%20only + https://slack.com/help/articles/212906697-Create-a-user-group + """ + + errors = ("paid_teams_only",) + + class SlackAPIInvalidUsersError(SlackAPIError): errors = ("invalid_users",) diff --git a/engine/apps/slack/models/slack_usergroup.py b/engine/apps/slack/models/slack_usergroup.py index acb172642e..93d0341a57 100644 --- a/engine/apps/slack/models/slack_usergroup.py +++ b/engine/apps/slack/models/slack_usergroup.py @@ -16,9 +16,10 @@ SlackAPIPermissionDeniedError, SlackAPITokenError, SlackAPIUsergroupNotFoundError, + SlackAPIUsergroupPaidTeamOnlyError, ) -from apps.slack.models import SlackTeamIdentity -from apps.user_management.models.user import User +from apps.slack.models import SlackTeamIdentity, SlackUserIdentity +from apps.user_management.models import User from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -85,9 +86,11 @@ def can_be_updated(self) -> bool: return False @property - def oncall_slack_user_identities(self): - users = set(user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule) - slack_user_identities = [] + def oncall_slack_user_identities(self) -> list[SlackUserIdentity]: + users: set[User] = set( + user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule + ) + slack_user_identities: list[SlackUserIdentity] = [] for user in users: if user.slack_user_identity is not None: slack_user_identities.append(user.slack_user_identity) @@ -96,7 +99,7 @@ def oncall_slack_user_identities(self): return slack_user_identities - def update_oncall_members(self): + def update_oncall_members(self) -> None: slack_ids = [slack_user_identity.slack_id for slack_user_identity in self.oncall_slack_user_identities] logger.info(f"Updating usergroup {self.slack_id}, members {slack_ids}") @@ -110,20 +113,25 @@ def update_oncall_members(self): logger.info(f"Skipping usergroup {self.slack_id}, already populated correctly") return - logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}") + logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}") try: self.update_members(slack_ids) except SlackAPIPermissionDeniedError: pass - def update_members(self, slack_ids): + def update_members(self, slack_ids: list[str]) -> None: sc = SlackClient(self.slack_team_identity, enable_ratelimit_retry=True) try: sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids) except (SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError) as err: logger.warning(f"Slack usergroup {self.slack_id} update failed: {err}") + except SlackAPIUsergroupPaidTeamOnlyError: + logger.warning( + f"Slack usergroup {self.slack_id} update failed as this feature is only available for paid teams", + exc_info=True, + ) except SlackAPIError as slack_api_error: logger.warning(f"Slack usergroup {self.slack_id} update failed: {slack_api_error}") raise diff --git a/engine/apps/slack/tests/test_slack_client.py b/engine/apps/slack/tests/test_slack_client.py index e5b3def4f9..da780dc0d2 100644 --- a/engine/apps/slack/tests/test_slack_client.py +++ b/engine/apps/slack/tests/test_slack_client.py @@ -25,6 +25,7 @@ SlackAPIServerError, SlackAPITokenError, SlackAPIUsergroupNotFoundError, + SlackAPIUsergroupPaidTeamOnlyError, SlackAPIUserNotFoundError, SlackAPIViewNotFoundError, ) @@ -129,6 +130,7 @@ def test_slack_client_generic_error(mock_request, monkeypatch, make_organization ("message_not_found", SlackAPIMessageNotFoundError), ("method_not_supported_for_channel_type", SlackAPIMethodNotSupportedForChannelTypeError), ("no_such_subteam", SlackAPIUsergroupNotFoundError), + ("paid_team_only", SlackAPIUsergroupPaidTeamOnlyError), ("not_found", SlackAPIViewNotFoundError), ("permission_denied", SlackAPIPermissionDeniedError), ("plan_upgrade_required", SlackAPIPlanUpgradeRequiredError), diff --git a/engine/apps/slack/tests/test_user_group.py b/engine/apps/slack/tests/test_user_group.py index ea06bd49dd..f24a53e6ae 100644 --- a/engine/apps/slack/tests/test_user_group.py +++ b/engine/apps/slack/tests/test_user_group.py @@ -9,6 +9,7 @@ SlackAPIInvalidUsersError, SlackAPITokenError, SlackAPIUsergroupNotFoundError, + SlackAPIUsergroupPaidTeamOnlyError, ) from apps.slack.models import SlackUserGroup from apps.slack.tasks import ( @@ -22,7 +23,7 @@ @pytest.mark.django_db def test_update_members(make_organization_with_slack_team_identity, make_slack_user_group): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() user_group = make_slack_user_group(slack_team_identity) slack_ids = ["slack_id_1", "slack_id_2"] @@ -34,13 +35,21 @@ def test_update_members(make_organization_with_slack_team_identity, make_slack_u @pytest.mark.django_db -@pytest.mark.parametrize("exception", [SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError]) +@pytest.mark.parametrize( + "exception", + [ + SlackAPITokenError, + SlackAPIUsergroupNotFoundError, + SlackAPIInvalidUsersError, + SlackAPIUsergroupPaidTeamOnlyError, + ], +) def test_slack_user_group_update_errors( make_organization_with_slack_team_identity, make_slack_user_group, exception, ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() user_group = make_slack_user_group(slack_team_identity=slack_team_identity) slack_ids = ["slack_id_1", "slack_id_2"] @@ -56,7 +65,7 @@ def test_slack_user_group_update_errors_raise( make_organization_with_slack_team_identity, make_slack_user_group, ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() user_group = make_slack_user_group(slack_team_identity=slack_team_identity) slack_ids = ["slack_id_1", "slack_id_2"] @@ -100,10 +109,10 @@ def test_update_oncall_members( organization, slack_team_identity = make_organization_with_slack_team_identity() user_group = make_slack_user_group(slack_team_identity) - user_1, slack_user_identity_1 = make_user_with_slack_user_identity( + _, slack_user_identity_1 = make_user_with_slack_user_identity( slack_team_identity, organization, slack_id="slack_id_1" ) - user_2, slack_user_identity_2 = make_user_with_slack_user_identity( + _, slack_user_identity_2 = make_user_with_slack_user_identity( slack_team_identity, organization, slack_id="slack_id_2" ) @@ -158,7 +167,7 @@ def test_start_update_slack_user_group_for_schedules_organization_deleted( def test_update_or_create_slack_usergroup_from_slack( mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() SlackUserGroup.update_or_create_slack_usergroup_from_slack("test_slack_id", slack_team_identity) usergroup = SlackUserGroup.objects.get() @@ -182,7 +191,7 @@ def test_update_or_create_slack_usergroup_from_slack( def test_update_or_create_slack_usergroup_from_slack_group_not_found( mock_usergroups_list, make_organization_with_slack_team_identity ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() SlackUserGroup.update_or_create_slack_usergroup_from_slack("other_id", slack_team_identity) # no group is created, no error is raised @@ -208,7 +217,7 @@ def test_update_or_create_slack_usergroup_from_slack_group_not_found( def test_populate_slack_usergroups_for_team( mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + _, slack_team_identity = make_organization_with_slack_team_identity() populate_slack_usergroups_for_team(slack_team_identity.pk) usergroup = SlackUserGroup.objects.get() @@ -226,12 +235,8 @@ def test_get_users_from_members_for_organization( ): organization, slack_team_identity = make_organization_with_slack_team_identity() - user_1, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="slack_id_1" - ) - user_2, slack_user_identity_2 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="slack_id_2" - ) + user_1, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_1") + user_2, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_2") user_group = make_slack_user_group(slack_team_identity) user_group.members = ["slack_id_1", "slack_id_2"] user_group.save(update_fields=["members"]) diff --git a/engine/apps/social_auth/pipeline/google.py b/engine/apps/social_auth/pipeline/google.py index 37702b397a..bed5996bef 100644 --- a/engine/apps/social_auth/pipeline/google.py +++ b/engine/apps/social_auth/pipeline/google.py @@ -19,7 +19,7 @@ def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response: https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 """ - user.finish_google_oauth2_connection_flow(response) + user.save_google_oauth2_settings(response) def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs): @@ -58,6 +58,6 @@ def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: else: logger.info(f"Google OAuth2 token for user {user_pk} is already invalid or revoked, ignoring error") - user.finish_google_oauth2_disconnection_flow() + user.reset_google_oauth2_settings() logger.info(f"Successfully disconnected user {user.pk} from Google OAuth2") diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 1e4592079e..971638837e 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -382,8 +382,14 @@ def update_alert_group_table_selected_columns(self, columns: typing.List[AlertGr self.alert_group_table_selected_columns = columns self.save(update_fields=["alert_group_table_selected_columns"]) - def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None: - _obj, created = GoogleOAuth2User.objects.update_or_create( + def save_google_oauth2_settings(self, google_oauth2_response: "GoogleOauth2Response") -> None: + logger.info( + f"Saving Google OAuth2 settings for user {self.pk} " + f"sub={google_oauth2_response.get('sub')} " + f"oauth_scope={google_oauth2_response.get('oauth_scope')}" + ) + + _, created = GoogleOAuth2User.objects.update_or_create( user=self, defaults={ "google_user_id": google_oauth2_response.get("sub"), @@ -398,7 +404,9 @@ def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOa } self.save(update_fields=["google_calendar_settings"]) - def finish_google_oauth2_disconnection_flow(self) -> None: + def reset_google_oauth2_settings(self) -> None: + logger.info(f"Resetting Google OAuth2 settings for user {self.pk}") + GoogleOAuth2User.objects.filter(user=self).delete() self.google_calendar_settings = None diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 7dc8a38451..54e95788bf 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -121,7 +121,7 @@ def test_has_google_oauth2_connected(make_organization_and_user, make_google_oau @pytest.mark.django_db -def test_finish_google_oauth2_connection_flow(make_organization_and_user): +def test_save_google_oauth2_settings(make_organization_and_user): oauth_response = { "access_token": "access", "refresh_token": "refresh", @@ -134,7 +134,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user): assert GoogleOAuth2User.objects.filter(user=user).exists() is False assert user.google_calendar_settings is None - user.finish_google_oauth2_connection_flow(oauth_response) + user.save_google_oauth2_settings(oauth_response) user.refresh_from_db() google_oauth_user = user.google_oauth2_user @@ -151,7 +151,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user): "scope": "scope2", } - user.finish_google_oauth2_connection_flow(oauth_response2) + user.save_google_oauth2_settings(oauth_response2) user.refresh_from_db() google_oauth_user = user.google_oauth2_user @@ -162,10 +162,10 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user): @pytest.mark.django_db -def test_finish_google_oauth2_disconnection_flow(make_organization_and_user): +def test_reset_google_oauth2_settings(make_organization_and_user): _, user = make_organization_and_user() - user.finish_google_oauth2_connection_flow( + user.save_google_oauth2_settings( { "access_token": "access", "refresh_token": "refresh", @@ -178,7 +178,7 @@ def test_finish_google_oauth2_disconnection_flow(make_organization_and_user): assert user.google_oauth2_user is not None assert user.google_calendar_settings is not None - user.finish_google_oauth2_disconnection_flow() + user.reset_google_oauth2_settings() user.refresh_from_db() assert GoogleOAuth2User.objects.filter(user=user).exists() is False diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 93bd2c145b..ea89b2e26c 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -198,6 +198,13 @@ def make_request( status["request_headers"] = error = e.message except InvalidWebhookData as e: status["request_data"] = error = e.message + except requests.exceptions.SSLError as e: + # Don't raise an exception for SSL errors, as they are out of our control and retrying + # isn't going to help. Just show the error to the user and give up + # + # from the docs (https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification) + # "Requests will throw a SSLError if it’s unable to verify the certificate" + status["content"] = error = str(e) except Exception as e: status["content"] = error = str(e) exception = e diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 381b79eb2f..38445a8a73 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -736,6 +736,56 @@ def test_execute_webhook_errors( ) +@patch( + "apps.webhooks.models.webhook.WebhookSession.request", + side_effect=requests.exceptions.SSLError("SSL error - foo bar"), +) +@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") # make it a valid URL when resolving name +@pytest.mark.django_db +def test_execute_webhook_ssl_error( + _mock_socket_gethostbyname, + mock_request, + make_organization, + make_alert_receive_channel, + make_alert_group, + make_custom_webhook, +): + url = "https://something.cool/" + expected_error = "SSL error - foo bar" + + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved_at=timezone.now(), resolved=True) + webhook = make_custom_webhook( + organization=organization, + http_method="POST", + trigger_type=Webhook.TRIGGER_RESOLVE, + forward_all=False, + url=url, + ) + + execute_webhook(webhook.pk, alert_group.pk, None, None) + + mock_request.assert_has_calls([call("POST", url, timeout=4, headers={})]) + + log = webhook.responses.all()[0] + assert log.status_code is None + assert log.content == expected_error + + # check log record + log_record = alert_group.log_records.last() + assert log_record.type == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR + assert log_record.step_specific_info == { + "trigger": "resolve", + "webhook_id": webhook.public_primary_key, + "webhook_name": webhook.name, + } + assert log_record.reason == expected_error + assert ( + log_record.rendered_log_line_action() == f"skipped resolve outgoing webhook `{webhook.name}`: {expected_error}" + ) + + @pytest.mark.django_db def test_response_content_limit( make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_custom_webhook diff --git a/engine/pyproject.toml b/engine/pyproject.toml index a1bab7d7c9..e6708a8e30 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -57,6 +57,7 @@ module = [ "factory.*", "fcm_django.*", "firebase_admin.*", + "google.auth.exceptions.*", "googleapiclient.discovery.*", "googleapiclient.errors.*", "google.oauth2.credentials.*", diff --git a/grafana-plugin/src/models/slack/slack.ts b/grafana-plugin/src/models/slack/slack.ts index fb665b9767..6a4b6ce82d 100644 --- a/grafana-plugin/src/models/slack/slack.ts +++ b/grafana-plugin/src/models/slack/slack.ts @@ -82,10 +82,10 @@ export class SlackStore extends BaseStore { window.location = url_for_redirect; } + @action.bound async installSlackIntegration() { try { const response = await makeRequestRaw('/login/slack-install-free/', {}); - if (response.status === 201) { this.rootStore.organizationStore.loadCurrentOrganization(); } else if (response.status === 200) { diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.module.css b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.module.css index 49111eb0ad..ab38f543e3 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.module.css +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.module.css @@ -39,3 +39,18 @@ .infoblock-icon { margin-top: 24px; } + +.upgradeSlackBtn { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); +} + +.upgradeSlackAlert svg { + display: none; +} + +.linkToIncidentWrapper { + margin-top: 16px; +} 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 462824dd95..213f3b6bc7 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 @@ -10,11 +10,13 @@ import { InlineField, Input, Legend, + ConfirmModal, } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { Block } from 'components/GBlock/Block'; +import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge'; import { PluginLink } from 'components/PluginLink/PluginLink'; import { Text } from 'components/Text/Text'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; @@ -26,9 +28,11 @@ import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config' import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; import { UserActions } from 'utils/authorization/authorization'; import { DOCS_SLACK_SETUP, getPluginId } from 'utils/consts'; +import { useConfirmModal } from 'utils/hooks'; import { showApiError } from 'utils/utils'; import styles from './SlackSettings.module.css'; @@ -116,9 +120,12 @@ class _SlackSettings extends Component { slackChannelStore: { items: slackChannelItems }, } = store; + const isUnifiedSlackInstalled = !currentOrganization.slack_team_identity.needs_reinstall; + return (
Slack App settings + {currentOrganization.slack_team_identity.needs_reinstall && } @@ -147,33 +154,58 @@ class _SlackSettings extends Component { /> - -

Are you sure to delete this Slack Integration?

-

- Removing the integration will also irreverisbly remove the following data for your OnCall plugin: -

-
    -
  • default organization Slack channel
  • -
  • default Slack channels for OnCall Integrations
  • -
  • Slack channels & Slack user groups for OnCall Schedules
  • -
  • linked Slack usernames for OnCall Users
  • -
-
-

- If you would like to instead remove your linked Slack username, please head{' '} - here. -

- - } - confirmationText="DELETE" - > - -
+ {isUnifiedSlackInstalled ? ( + +

Are you sure to delete this Slack Integration? It will affect both OnCall & Incident.

+

Removing the integration will irreverisbly remove the following data for IRM;

+
    +
  • OnCall default Slack channel
  • +
  • Slack channels for OnCall escalation policies
  • +
  • Slack channels & Slack user groups for OnCall Schedules
  • +
  • linked Slack usernames for OnCall Users
  • +
  • Incident hooks
  • +
+
+ + } + confirmationText="DELETE" + > + +
+ ) : ( + +

Are you sure to delete this Slack Integration?

+

+ Removing the integration will also irreverisbly remove the following data for your OnCall plugin: +

+
    +
  • default organization Slack channel
  • +
  • default Slack channels for OnCall Integrations
  • +
  • Slack channels & Slack user groups for OnCall Schedules
  • +
  • linked Slack usernames for OnCall Users
  • +
+
+

+ If you would like to instead remove your linked Slack username, please head{' '} + here. +

+ + } + confirmationText="DELETE" + > + +
+ )}
Additional settings @@ -201,19 +233,20 @@ class _SlackSettings extends Component { - {currentOrganization.slack_team_identity.needs_reinstall && ( - <> - Unified Slack App - - - - - - + {isUnifiedSlackInstalled && ( + )}
); @@ -251,6 +284,7 @@ class _SlackSettings extends Component { const { store } = this.props; const { showENVVariablesButton } = this.state; const isLiveSettingAvailable = store.hasFeature(AppFeature.LiveSettings) && showENVVariablesButton; + const isUnifiedSlackEnabled = store.hasFeature(AppFeature.UnifiedSlack); return ( @@ -261,7 +295,9 @@ class _SlackSettings extends Component { - Connecting Slack App will allow you to manage alert groups in your team Slack workspace. + {isUnifiedSlackEnabled + ? 'Connecting Slack App will allow you to manage alert groups and incidents in your team Slack workspace.' + : 'Connecting Slack App will allow you to manage alert groups in your team Slack workspace.'} After a basic workspace connection your team members need to connect their personal Slack accounts in @@ -305,4 +341,61 @@ class _SlackSettings extends Component { }; } +const UpgradeToUnifiedSlackBanner = observer(() => { + const { + slackStore: { installSlackIntegration }, + } = useStore(); + const { modalProps, openModal } = useConfirmModal(); + + return ( + <> + + Upgrade} + > + We've rebranded the OnCall Slack app as the Grafana IRM Slack app, now with incident management features. +

Click "Upgrade" to reviewn and approve the new permissions and complete the process.

+

For more details, check our documentation.

+ +
+ + ); +}); + export const SlackSettings = withMobXProviderContext(_SlackSettings); diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index a5c5af265e..b36b0a1de8 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -1,5 +1,6 @@ export enum AppFeature { Slack = 'slack', + UnifiedSlack = 'unified_slack', Telegram = 'telegram', LiveSettings = 'live_settings', CloudNotifications = 'grafana_cloud_notifications',