From 6b87ad74e9695fc77ae3c55fe0ea7ce9dfab2aec Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 19 Jan 2023 11:15:56 +0000 Subject: [PATCH 01/33] Enforce cloud connection to send push notifications on OSS (#1132) This PR modifies how OSS instances send mobile app push notifications. It also adds frontend warnings when user is trying to use the mobile app without connecting to cloud. - [x] Add public API authentication to `FCMRelayView` and throttle the view to 300 push notifications per instance per minute. This is similar to how SMS and phone call notifications work on OSS instances. - [x] Add frontend warnings based on cloud connectivity - [x] Fix/add frontend tests - [x] Add tests for FCMRelayView and mobile app backend ## Screenshots When a user tries to connect the mobile app in his settings and cloud is not connected (clicking "Connect Cloud OnCall" redirects to the "Cloud" tab): Screenshot 2023-01-12 at 18 48 58 When a user tries to use mobile push notifications as a personal notification step and cloud is not connected: Screenshot 2023-01-12 at 19 01 10 Now on the "Cloud" tab there's some info about the mobile app (the last section at the bottom of the page): Screenshot 2023-01-12 at 18 49 10 After connecting to the cloud instance, everything goes back to active and it's now possible to connect the mobile app: Screenshot 2023-01-12 at 19 08 27 After connecting the app the warning is gone: Screenshot 2023-01-12 at 19 07 00 --- engine/apps/mobile_app/fcm_relay.py | 26 ++- engine/apps/mobile_app/tasks.py | 45 ++++- engine/apps/mobile_app/tests/__init__.py | 0 .../apps/mobile_app/tests/test_fcm_relay.py | 81 +++++++++ .../apps/mobile_app/tests/test_notify_user.py | 171 ++++++++++++++++++ engine/apps/mobile_app/urls.py | 9 +- engine/conftest.py | 20 +- .../components/Policy/NotificationPolicy.tsx | 22 +++ .../MobileAppConnection.test.tsx | 42 ++++- .../MobileAppConnection.tsx | 29 ++- .../MobileAppConnection.test.tsx.snap | 53 ++++++ .../PersonalNotificationSettings.tsx | 7 + .../CloudPhoneSettings/CloudPhoneSettings.tsx | 4 +- grafana-plugin/src/models/cloud/cloud.ts | 7 + .../pages/settings/tabs/Cloud/CloudPage.tsx | 33 +++- .../src/state/rootBaseStore/index.ts | 11 +- 16 files changed, 527 insertions(+), 33 deletions(-) create mode 100644 engine/apps/mobile_app/tests/__init__.py create mode 100644 engine/apps/mobile_app/tests/test_fcm_relay.py create mode 100644 engine/apps/mobile_app/tests/test_notify_user.py diff --git a/engine/apps/mobile_app/fcm_relay.py b/engine/apps/mobile_app/fcm_relay.py index bcf63a55..717bfa34 100644 --- a/engine/apps/mobile_app/fcm_relay.py +++ b/engine/apps/mobile_app/fcm_relay.py @@ -5,25 +5,37 @@ from django.conf import settings from fcm_django.models import FCMDevice from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message from rest_framework import status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView +from apps.auth_token.auth import ApiTokenAuthentication from common.custom_celery_tasks import shared_dedicated_queue_retry_task task_logger = get_task_logger(__name__) task_logger.setLevel(logging.DEBUG) +class FCMRelayThrottler(UserRateThrottle): + scope = "fcm_relay" + rate = "300/m" + + class FCMRelayView(APIView): - # TODO: use public API authentication (then it would be required to connect to a cloud instance to use the app) - authentication_classes = [] - permission_classes = [] + """ + This view accepts push notifications from OSS instances and forwards these requests to FCM. + Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay. + The view uses public API authentication, so an OSS instance must be connected to cloud to use FCM relay. + """ + + authentication_classes = [ApiTokenAuthentication] + permission_classes = [IsAuthenticated] + throttle_classes = [FCMRelayThrottler] def post(self, request): - """ - This view accepts push notifications from OSS instances and forwards these requests to FCM. - Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay - """ + if not settings.FCM_RELAY_ENABLED: + return Response(status=status.HTTP_404_NOT_FOUND) try: token = request.data["token"] diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index d3198adb..4d9a098b 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -6,9 +6,13 @@ from celery.utils.log import get_task_logger from django.conf import settings from fcm_django.models import FCMDevice from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message +from requests import HTTPError +from rest_framework import status from apps.alerts.models import AlertGroup +from apps.base.utils import live_settings from apps.mobile_app.alert_rendering import get_push_notification_message +from apps.oss_installation.models import CloudConnector from apps.user_management.models import User from common.api_helpers.utils import create_engine_url from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -41,10 +45,10 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) logger.warning(f"User notification policy {notification_policy_pk} does not exist") return - device_to_notify = FCMDevice.objects.filter(user=user).first() - - # create an error log in case user has no devices set up - if not device_to_notify: + def _create_error_log_record(): + """ + Utility method to create a UserNotificationPolicyLogRecord with error + """ UserNotificationPolicyLogRecord.objects.create( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -54,7 +58,13 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) notification_step=notification_policy.step, notification_channel=notification_policy.notify_by, ) - logger.info(f"Error while sending a mobile push notification: user {user_pk} has no device set up") + + device_to_notify = FCMDevice.objects.filter(user=user).first() + + # create an error log in case user has no devices set up + if not device_to_notify: + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up") return thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" @@ -116,8 +126,25 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};") if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME: - response = send_push_notification_to_fcm_relay(message) - logger.debug(f"FCM relay response: {response}") + # FCM relay uses cloud connection to send push notifications + if not CloudConnector.objects.exists(): + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: not connected to cloud") + return + + try: + response = send_push_notification_to_fcm_relay(message) + logger.debug(f"FCM relay response: {response}") + except HTTPError as e: + if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: + # do not retry on HTTP client errors (4xx errors) + _create_error_log_record() + logger.error( + f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}" + ) + return + else: + raise else: response = device_to_notify.send_message(message) # NOTE: we may want to further handle the response from FCM, but for now lets simply log it out @@ -131,7 +158,9 @@ def send_push_notification_to_fcm_relay(message): """ url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - response = requests.post(url, json=json.loads(str(message))) + response = requests.post( + url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message)) + ) response.raise_for_status() return response diff --git a/engine/apps/mobile_app/tests/__init__.py b/engine/apps/mobile_app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mobile_app/tests/test_fcm_relay.py b/engine/apps/mobile_app/tests/test_fcm_relay.py new file mode 100644 index 00000000..23612eac --- /dev/null +++ b/engine/apps/mobile_app/tests/test_fcm_relay.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.mobile_app.fcm_relay import FCMRelayThrottler + + +@pytest.mark.django_db +def test_fcm_relay_disabled( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = False + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + response = client.post(url, HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_fcm_relay_post( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + data = { + "token": "test_registration_id", + "data": {}, + "apns": {}, + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_fcm_relay_ratelimit( + settings, + load_mobile_app_urls, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, +): + settings.FCM_RELAY_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + _, token = make_public_api_token(user, organization) + + client = APIClient() + url = reverse("mobile_app:fcm_relay") + + data = { + "token": "test_registration_id", + "data": {}, + "apns": {}, + } + + with patch.object(FCMRelayThrottler, "rate", "0/m"): + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py new file mode 100644 index 00000000..df18105c --- /dev/null +++ b/engine/apps/mobile_app/tests/test_notify_user.py @@ -0,0 +1,171 @@ +from unittest.mock import patch + +import pytest +from fcm_django.models import FCMDevice + +from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +from apps.mobile_app.tasks import notify_user_async +from apps.oss_installation.models import CloudConnector + +MOBILE_APP_BACKEND_ID = 5 +CLOUD_LICENSE_NAME = "Cloud" +OPEN_SOURCE_LICENSE_NAME = "OpenSource" + + +@pytest.mark.django_db +def test_notify_user_async_cloud( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # check FCM is contacted directly when using the cloud license + settings.LICENSE = CLOUD_LICENSE_NAME + with patch.object(FCMDevice, "send_message", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_called() + + +@pytest.mark.django_db +def test_notify_user_async_oss( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_called() + + +@pytest.mark.django_db +def test_notify_user_async_oss_no_device_connected( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user without mobile device + organization, user = make_organization_and_user() + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_not_called() + + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + + +@pytest.mark.django_db +def test_notify_user_async_oss_no_cloud_connection( + settings, + make_organization_and_user, + make_user_notification_policy, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + # create a user and connect a mobile device + organization, user = make_organization_and_user() + FCMDevice.objects.create(user=user, registration_id="test_device_id") + + # set up notification policy and alert group + notification_policy = make_user_notification_policy( + user, + UserNotificationPolicy.Step.NOTIFY, + notify_by=MOBILE_APP_BACKEND_ID, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + channel_filter = make_channel_filter(alert_receive_channel) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data={}) + + # check FCM relay is contacted when using the OSS license + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + with patch("apps.mobile_app.tasks.send_push_notification_to_fcm_relay", return_value="ok") as mock: + notify_user_async( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=False, + ) + mock.assert_not_called() + + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 8b14dc7c..2f0433d9 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,5 +1,3 @@ -from django.conf import settings - from apps.mobile_app.fcm_relay import FCMRelayView from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path @@ -14,7 +12,6 @@ urlpatterns = [ optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), ] -if settings.FCM_RELAY_ENABLED: - urlpatterns += [ - optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"), - ] +urlpatterns += [ + optional_slash_path("fcm_relay", FCMRelayView.as_view(), name="fcm_relay"), +] diff --git a/engine/conftest.py b/engine/conftest.py index 353b194c..2a34be16 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -736,10 +736,12 @@ def make_integration_heartbeat(): return _make_integration_heartbeat -@pytest.fixture() -def load_slack_urls(settings): +def reload_urls(settings): + """ + Reloads Django URLs, especially useful when testing conditionally registered URLs + """ + clear_url_caches() - settings.FEATURE_SLACK_INTEGRATION_ENABLED = True urlconf = settings.ROOT_URLCONF if urlconf in sys.modules: reload(sys.modules[urlconf]) @@ -747,6 +749,18 @@ def load_slack_urls(settings): import_module(urlconf) +@pytest.fixture() +def load_slack_urls(settings): + settings.FEATURE_SLACK_INTEGRATION_ENABLED = True + reload_urls(settings) + + +@pytest.fixture() +def load_mobile_app_urls(settings): + settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED = True + reload_urls(settings) + + @pytest.fixture def make_region(): def _make_region(**kwargs): diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index b897dfc7..b51c0d90 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -36,6 +36,8 @@ export interface NotificationPolicyProps { notifyByOptions?: NotifyBy[]; telegramVerified: boolean; phoneStatus: number; + isMobileAppConnected: boolean; + showCloudConnectionWarning: boolean; color: string; number: number; userAction: UserAction; @@ -132,6 +134,20 @@ export class NotificationPolicy extends React.ComponentCloud is not connected; + } + + if (!isMobileAppConnected) { + return Mobile app is not connected; + } + + return Mobile app is connected; + } + _renderTelegramNote() { const { telegramVerified } = this.props; @@ -203,6 +219,12 @@ export class NotificationPolicy extends React.Component{this._renderTelegramNote()}; + case 5: + return <>{this._renderMobileAppNote()}; + + case 6: + return <>{this._renderMobileAppNote()}; + default: return null; } diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx index 4967b9bb..5e8e3749 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { CloudStore } from 'models/cloud/cloud'; import { UserStore } from 'models/user/user'; import { User } from 'models/user/user.types'; import { RootStore } from 'state'; @@ -10,12 +12,33 @@ import { useStore as useStoreOriginal } from 'state/useStore'; import MobileAppConnection from './MobileAppConnection'; +jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({ + isTopNavbar: () => false, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + topNav: false, + }, + }, +})); + +jest.mock('utils/authorization', () => ({ + ...jest.requireActual('utils/authorization'), + isUserActionAllowed: jest.fn().mockReturnValue(true), +})); + +jest.mock('@grafana/runtime', () => ({ + getLocationSrv: jest.fn(), +})); + jest.mock('state/useStore'); const useStore = useStoreOriginal as jest.Mock>; const loadUserMock = jest.fn().mockReturnValue(undefined); -const mockUseStore = (rest?: any, connected = false) => { +const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => { const store = { userStore: { loadUser: loadUserMock, @@ -26,6 +49,11 @@ const mockUseStore = (rest?: any, connected = false) => { } as unknown as User, ...(rest ? rest : {}), } as unknown as UserStore, + cloudStore: { + getCloudConnectionStatus: jest.fn().mockReturnValue({ cloud_connection_status: cloud_connected }), + cloudConnectionStatus: { cloud_connection_status: cloud_connected }, + } as unknown as CloudStore, + hasFeature: jest.fn().mockReturnValue(true), } as unknown as RootStore; useStore.mockReturnValue(store); @@ -232,4 +260,16 @@ describe('MobileAppConnection', () => { { timeout: 6000 } ); }); + + test('it shows a warning when cloud is not connected', async () => { + mockUseStore({}, true, false); + + // Using MemoryRouter to avoid "Invariant failed: You should not use outside a " + const component = render( + + + + ); + expect(component.container).toMatchSnapshot(); + }); }); diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index f09d73dc..39de4a98 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -1,14 +1,17 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import { Button, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import qrCodeImage from 'assets/img/qr-code.png'; import Block from 'components/GBlock/Block'; +import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; import styles from './MobileAppConnection.module.scss'; import DisconnectButton from './parts/DisconnectButton/DisconnectButton'; @@ -29,7 +32,29 @@ const INTERVAL_POLLING = 5000; const BACKEND = 'MOBILE_APP'; const MobileAppConnection = observer(({ userPk }: Props) => { - const { userStore } = useStore(); + const store = useStore(); + const { userStore, cloudStore } = store; + + // Show link to cloud page for OSS instances with no cloud connection + if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) { + return ( + + Please connect Cloud OnCall to use the mobile app + {isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( + + + + ) : ( + + You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your + permissions. + + )} + + ); + } const isMounted = useRef(false); const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState(isUserConnected()); 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 d45f37d1..6284efb3 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -2774,6 +2774,59 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c `; +exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = ` +
+
+
+ + Please connect Cloud OnCall to use the mobile app + +
+ +
+
+`; + exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
{title} @@ -134,6 +139,8 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin number={index + 1} telegramVerified={Boolean(user.telegram_configuration)} phoneStatus={getPhoneStatus()} + isMobileAppConnected={isMobileAppConnected} + showCloudConnectionWarning={showCloudConnectionWarning} slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity} slackUserIdentity={user.slack_user_identity} data={notificationPolicy} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index ecf2b50c..e6502cc5 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -122,7 +122,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { {isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( - OnCall use Grafana Cloud for SMS and phone call notifications + OnCall uses Grafana Cloud for SMS and phone call notifications {syncing ? ( From 5ef8b8c345af04424b384049f3b9d28de032e8b2 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 20 Jan 2023 15:23:17 +0100 Subject: [PATCH 18/33] remove duplicate call in UI to GET /alert_receive_channels (#1179) # What this PR does Related to [this PR comment](https://github.com/grafana/oncall/pull/1164#discussion_r1082337697) from @maskin25 ## Which issue(s) this PR fixes **Before** ![Screenshot 2023-01-20 at 14 24 07](https://user-images.githubusercontent.com/9406895/213706172-1f219346-7e88-4e10-b2f3-c37590ecb43d.png) **After** ![Screenshot 2023-01-20 at 14 26 05](https://user-images.githubusercontent.com/9406895/213706194-d95fc5f0-1494-4efc-ae92-31f1771ec490.png) ## Checklist - [ ] Tests updated (N/A) - [ ] Documentation added (N/A) - [x] `CHANGELOG.md` updated --- CHANGELOG.md | 6 +++++- grafana-plugin/src/state/rootBaseStore/index.ts | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d4779d..a5b62652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Slack slash command allowing to trigger a direct page via a manually created alert group +### Fixed + +- Removed duplicate API call, in the UI on plugin initial load, to `GET /api/internal/v1/alert_receive_channels` + ## v1.1.18 (2023-01-18) ### Added @@ -26,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Modified how the `Organization.is_rbac_permissions_enabled` flag is set, -based on whether we are dealing with an open-source, or cloud installation + based on whether we are dealing with an open-source, or cloud installation - Backend implementation to support direct user/schedule paging - Changed documentation links to open in new window - Remove helm chart signing diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index d2300dc0..3f936175 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -115,7 +115,6 @@ export class RootBaseStore { this.userStore.updateNotificationPolicyOptions(), this.userStore.updateNotifyByOptions(), this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(), - this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(), this.escalationPolicyStore.updateWebEscalationPolicyOptions(), this.escalationPolicyStore.updateEscalationPolicyOptions(), this.escalationPolicyStore.updateNumMinutesInWindowOptions(), From 83b1f069d08d1976bf7d9601ff6ab6681229ed82 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Sat, 21 Jan 2023 21:59:20 +0800 Subject: [PATCH 19/33] Optimize alertgroups endpoint (#1186) # What this PR does ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- engine/Dockerfile | 2 +- engine/apps/api/views/alert_group.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/engine/Dockerfile b/engine/Dockerfile index dcc0c1ea..15ff4503 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -20,7 +20,7 @@ COPY ./ ./ # Collect static files and create an SQLite database RUN mkdir -p /var/lib/oncall -RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" python manage.py collectstatic --no-input +RUN DJANGO_SETTINGS_MODULE=settings.prod_without_db DATABASE_TYPE=sqlite3 DATABASE_NAME=/var/lib/oncall/oncall.db SECRET_KEY="ThEmUsTSecretKEYforBUILDstage123" SILK_PROFILER_ENABLED="True" python manage.py collectstatic --no-input RUN chown -R 1000:2000 /var/lib/oncall FROM base AS dev diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index bff96381..17f6a9c8 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -206,8 +206,14 @@ class AlertGroupView( def get_queryset(self): # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. + alert_receive_channels_ids = list( + AlertReceiveChannel.objects.filter( + organization_id=self.request.auth.organization.id, + team_id=self.request.user.current_team, + ).values_list("id", flat=True) + ) queryset = AlertGroup.unarchived_objects.filter( - channel__organization=self.request.auth.organization, channel__team=self.request.user.current_team + channel_id__in=alert_receive_channels_ids, ).only("id") return queryset From c9b83906a09e8c6d45b0a46c475e38fe3046537f Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Sun, 22 Jan 2023 00:14:48 +0800 Subject: [PATCH 20/33] Optimize alertgroups endpoint (#1188) # What this PR does Changing query to retrieve alert group in two requests instead of one with `join` old query: ``` SELECT `alerts_alertgroup`.`id` FROM `alerts_alertgroup` INNER JOIN `alerts_alertreceivechannel` ON (`alerts_alertgroup`.`channel_id` = `alerts_alertreceivechannel`.`id`) WHERE (`alerts_alertreceivechannel`.`organization_id` = 1 AND `alerts_alertreceivechannel`.`team_id` IS NULL AND NOT `alerts_alertgroup`.`is_archived` AND NOT `alerts_alertgroup`.`is_archived` AND `alerts_alertgroup`.`root_alert_group_id` IS NULL AND ((NOT `alerts_alertgroup`.`silenced` AND NOT `alerts_alertgroup`.`acknowledged` AND NOT `alerts_alertgroup`.`resolved`) OR (`alerts_alertgroup`.`acknowledged` AND NOT `alerts_alertgroup`.`resolved`)) AND NOT `alerts_alertgroup`.`is_archived`) ORDER BY `alerts_alertgroup`.`id` DESC LIMIT 26 ``` new query: ``` SELECT "alerts_alertgroup"."id" FROM "alerts_alertgroup" WHERE ("alerts_alertgroup"."channel_id" IN (SELECT U0."id" FROM "alerts_alertreceivechannel" U0 WHERE (NOT (U0."integration" = maintenance) AND U0."deleted_at" IS NULL AND U0."organization_id" = 1 AND U0."team_id" IS NULL)) AND NOT "alerts_alertgroup"."is_archived" AND NOT "alerts_alertgroup"."is_archived" AND "alerts_alertgroup"."root_alert_group_id" IS NULL AND ((NOT "alerts_alertgroup"."silenced" AND NOT "alerts_alertgroup"."acknowledged" AND NOT "alerts_alertgroup"."resolved") OR ("alerts_alertgroup"."acknowledged" AND NOT "alerts_alertgroup"."resolved")) AND NOT "alerts_alertgroup"."is_archived") ORDER BY "alerts_alertgroup"."id" DESC LIMIT 26 ``` ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- engine/apps/api/views/alert_group.py | 43 ++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 17f6a9c8..9f0ec306 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -1,11 +1,13 @@ from datetime import timedelta +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Max, Q from django.utils import timezone from django_filters import rest_framework as filters from django_filters.widgets import RangeWidget from rest_framework import mixins, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -15,9 +17,10 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel from apps.alerts.paging import unpage_user from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer +from apps.api.serializers.team import TeamSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication -from apps.user_management.models import User +from apps.user_management.models import Team, User from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import DateRangeFilterMixin, ModelFieldFilterMixin from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin @@ -148,6 +151,34 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt class AlertGroupTeamFilteringMixin(TeamFilteringMixin): TEAM_LOOKUP = "channel__team" + def retrieve(self, request, *args, **kwargs): + try: + return super().retrieve(request, *args, **kwargs) + except NotFound: + queryset = AlertGroup.unarchived_objects.filter( + channel__in=AlertReceiveChannel.objects.filter( + organization=self.request.auth.organization, + ), + ).only("public_primary_key") + + try: + obj = queryset.get(public_primary_key=self.kwargs["pk"]) + except ObjectDoesNotExist: + raise NotFound + + obj_team = self._getattr_with_related(obj, self.TEAM_LOOKUP) + + if obj_team is None or obj_team in self.request.user.teams.all(): + if obj_team is None: + obj_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None) + + return Response( + data={"error_code": "wrong_team", "owner_team": TeamSerializer(obj_team).data}, + status=status.HTTP_403_FORBIDDEN, + ) + + return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN) + class AlertGroupView( PreviewTemplateMixin, @@ -206,14 +237,10 @@ class AlertGroupView( def get_queryset(self): # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. - alert_receive_channels_ids = list( - AlertReceiveChannel.objects.filter( - organization_id=self.request.auth.organization.id, - team_id=self.request.user.current_team, - ).values_list("id", flat=True) - ) queryset = AlertGroup.unarchived_objects.filter( - channel_id__in=alert_receive_channels_ids, + channel__in=AlertReceiveChannel.objects.filter( + organization=self.request.auth.organization, team=self.request.user.current_team + ), ).only("id") return queryset From b90fe433c9152f754e05c9a6e4d0f50416eac3c0 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Sun, 22 Jan 2023 00:53:11 +0800 Subject: [PATCH 21/33] Optimize alertgroups endpoint (#1189) # What this PR does Changing query to retrieve alert group in two completely different queries instead of one with `join` new queries ``` SELECT alerts_alertreceivechannel.id FROM alerts_alertreceivechannel WHERE (alerts_alertreceivechannel.deleted_at IS NULL AND alerts_alertreceivechannel.organization_id = 8 AND alerts_alertreceivechannel.team_id IS NULL) SELECT `alerts_alertgroup`.`id` FROM `alerts_alertgroup` WHERE (`alerts_alertgroup`.`channel_id` IN (2,33,34,35,36,40,52,59,61,62,63,70,76,89,93,94,03,08,09,10,12,13,16,18,20,22,23,24,26,27,28,30,31,33,34,35,36,40,41,42,43,45,48,53,56,57,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,86,87,88,89,91,93,23,27,29,31,32,33,55,56,57,58,65,69,72,75,81,13,17,20,22,33,34,38,39,41,44,45,46,51,52,55,56,58,59,60,63,68,70,71) AND NOT `alerts_alertgroup`.`is_archived` AND NOT `alerts_alertgroup`.`is_archived` AND `alerts_alertgroup`.`root_alert_group_id` IS NULL AND ((NOT `alerts_alertgroup`.`silenced` AND NOT `alerts_alertgroup`.`acknowledged` AND NOT `alerts_alertgroup`.`resolved`) OR (`alerts_alertgroup`.`acknowledged` AND NOT `alerts_alertgroup`.`resolved`)) AND NOT `alerts_alertgroup`.`is_archived`) ORDER BY `alerts_alertgroup`.`id` DESC LIMIT 26 ``` ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- engine/apps/api/views/alert_group.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 9f0ec306..1dc8f281 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -155,10 +155,13 @@ class AlertGroupTeamFilteringMixin(TeamFilteringMixin): try: return super().retrieve(request, *args, **kwargs) except NotFound: + alert_receive_channels_ids = list( + AlertReceiveChannel.objects.filter( + organization_id=self.request.auth.organization.id, + ).values_list("id", flat=True) + ) queryset = AlertGroup.unarchived_objects.filter( - channel__in=AlertReceiveChannel.objects.filter( - organization=self.request.auth.organization, - ), + channel__in=alert_receive_channels_ids, ).only("public_primary_key") try: @@ -237,10 +240,14 @@ class AlertGroupView( def get_queryset(self): # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. + alert_receive_channels_ids = list( + AlertReceiveChannel.objects.filter( + organization_id=self.request.auth.organization.id, + team_id=self.request.user.current_team, + ).values_list("id", flat=True) + ) queryset = AlertGroup.unarchived_objects.filter( - channel__in=AlertReceiveChannel.objects.filter( - organization=self.request.auth.organization, team=self.request.user.current_team - ), + channel__in=alert_receive_channels_ids, ).only("id") return queryset From 639fd816447d99ef03692aadbfe5f44e5e45a59f Mon Sep 17 00:00:00 2001 From: Dan Cech Date: Mon, 23 Jan 2023 07:44:33 +0000 Subject: [PATCH 22/33] Update message when user needs to connect their profile (#1190) # What this PR does This just tweaks the message users get when they try to interact via slack but haven't connected their profile, it fixes a typo and streamlines the text. --- engine/apps/slack/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 458caadf..377c3f15 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -520,8 +520,8 @@ class SlackEventApiEndpointView(APIView): return text = ( - "The information in workspace is read-only. To be able to intercat with OnCall alert groups you need to connect a personal account.\n" - "Please go to the *Grafana* -> *OnCall* -> *Users*, " + "The information in this workspace is read-only. To interact with OnCall alert groups you need to connect a personal account.\n" + "Please go to *Grafana* -> *OnCall* -> *Users*, " "choose *your profile* and click the *connect* button.\n" ":rocket: :rocket: :rocket:" ) From 37d25b5b31019f477b0dfdf22fd6c5f240212d30 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 23 Jan 2023 16:07:55 +0800 Subject: [PATCH 23/33] Optimize alert group filtering queries (#1191) # What this PR does ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- engine/apps/alerts/models/alert_group.py | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index ee5b6a8d..fe7cdfc5 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -351,19 +351,39 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. @staticmethod def get_silenced_state_filter(): - return Q(silenced=True) & Q(acknowledged=False) & Q(resolved=False) + """ + models.Value(0/1) is used instead of True/False because django translates that into + WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field + which works much faster in mysql + """ + return Q(silenced=models.Value("1")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0")) @staticmethod def get_new_state_filter(): - return Q(silenced=False) & Q(acknowledged=False) & Q(resolved=False) + """ + models.Value(0/1) is used instead of True/False because django translates that into + WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field + which works much faster in mysql + """ + return Q(silenced=models.Value("0")) & Q(acknowledged=models.Value("0")) & Q(resolved=models.Value("0")) @staticmethod def get_acknowledged_state_filter(): - return Q(acknowledged=True) & Q(resolved=False) + """ + models.Value(0/1) is used instead of True/False because django translates that into + WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field + which works much faster in mysql + """ + return Q(acknowledged=models.Value("1")) & Q(resolved=models.Value("0")) @staticmethod def get_resolved_state_filter(): - return Q(resolved=True) + """ + models.Value(0/1) is used instead of True/False because django translates that into + WHERE bool_field=0/1 instead of WHERE bool_field/NOT bool_field + which works much faster in mysql + """ + return Q(resolved=models.Value("1")) class Meta: get_latest_by = "pk" From ae5949aa7e281696d3776d0253334cb976569e60 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 23 Jan 2023 11:17:57 +0000 Subject: [PATCH 24/33] Allow viewers fetch cloud connection status (#1181) # What this PR does Fixes the issue when users with the viewer role can't fetch the cloud connection status, which makes the plugin fail to load for viewers. This PR makes the cloud connection endpoint use `OTHER_SETTINGS_READ` for fetching the cloud connection status instead of `OTHER_SETTINGS_WRITE`. ## Checklist - [x] Tests updated - [x] `CHANGELOG.md` updated --- CHANGELOG.md | 4 ++ .../apps/oss_installation/tests/__init__.py | 0 .../apps/oss_installation/tests/test_views.py | 41 +++++++++++++++++++ engine/apps/oss_installation/urls.py | 2 + .../views/cloud_connection.py | 2 +- engine/engine/urls.py | 2 +- 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 engine/apps/oss_installation/tests/__init__.py create mode 100644 engine/apps/oss_installation/tests/test_views.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b62652..97e96543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Slack slash command allowing to trigger a direct page via a manually created alert group +### Changed + +- Allow users with `viewer` role to fetch cloud connection status using the internal API ([#1181](https://github.com/grafana/oncall/pull/1181)) + ### Fixed - Removed duplicate API call, in the UI on plugin initial load, to `GET /api/internal/v1/alert_receive_channels` diff --git a/engine/apps/oss_installation/tests/__init__.py b/engine/apps/oss_installation/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/oss_installation/tests/test_views.py b/engine/apps/oss_installation/tests/test_views.py new file mode 100644 index 00000000..3eaad082 --- /dev/null +++ b/engine/apps/oss_installation/tests/test_views.py @@ -0,0 +1,41 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.api.permissions import LegacyAccessControlRole +from apps.oss_installation.models import CloudConnector + + +@pytest.mark.django_db +def test_cloud_connection_viewer_can_read( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + client = APIClient() + url = reverse("oss_installation:cloud-connection-status") + + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_cloud_connection_viewer_cant_delete( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) + + # create cloud connection + CloudConnector.objects.create(cloud_url="test") + + client = APIClient() + url = reverse("oss_installation:cloud-connection-status") + response = client.delete(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index ddf04020..3856b887 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -4,6 +4,8 @@ from common.api_helpers.optional_slash_router import OptionalSlashRouter, option from .views import CloudConnectionView, CloudHeartbeatView, CloudUsersView, CloudUserView +app_name = "oss_installation" + router = OptionalSlashRouter() router.register("cloud_users", CloudUserView, basename="cloud-users") diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index de73343c..004837df 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -15,7 +15,7 @@ class CloudConnectionView(APIView): authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { - "get": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "get": [RBACPermission.Permissions.OTHER_SETTINGS_READ], "delete": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], } diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 77d0f80f..2ae1f2ef 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -60,7 +60,7 @@ if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: if settings.OSS_INSTALLATION: urlpatterns += [ - path("api/internal/v1/", include("apps.oss_installation.urls")), + path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")), ] if settings.DEBUG: From fd75a3e4ad921594ffe889882f22d82f9884aa7f Mon Sep 17 00:00:00 2001 From: Alyssa Wada <101596687+alyssawada@users.noreply.github.com> Date: Mon, 23 Jan 2023 14:44:28 -0700 Subject: [PATCH 25/33] oncall schedule doc updates v1 (#1197) This is a first iteration to establish initial on-call schedule doc improvements. Additional docs and content improvements in progress. # What this PR does Updates OnCall docs TOC to provide better on-call schedule documentation Updates _index.md to describe all on-call schedule options with Grafana OnCall Adds web-based schedule guidance to oncall docs - About web-based schedules - Create on-call schedule in Grafana OnCall - Export on-call schedules ## Which issue(s) this PR fixes Issue #935 Issue #1078 --- docs/sources/calendar-schedules/_index.md | 69 ++++++---------- .../ical-schedules/index.md | 79 +++++++++++++++++++ .../calendar-schedules/web-schedule/_index.md | 53 +++++++++++++ .../web-schedule/calendar-export/index.md | 67 ++++++++++++++++ .../web-schedule/create-schedule/index.md | 69 ++++++++++++++++ 5 files changed, 294 insertions(+), 43 deletions(-) create mode 100644 docs/sources/calendar-schedules/ical-schedules/index.md create mode 100644 docs/sources/calendar-schedules/web-schedule/_index.md create mode 100644 docs/sources/calendar-schedules/web-schedule/calendar-export/index.md create mode 100644 docs/sources/calendar-schedules/web-schedule/create-schedule/index.md diff --git a/docs/sources/calendar-schedules/_index.md b/docs/sources/calendar-schedules/_index.md index 991e409d..201aa5de 100644 --- a/docs/sources/calendar-schedules/_index.md +++ b/docs/sources/calendar-schedules/_index.md @@ -1,68 +1,51 @@ --- +title: On-call schedules aliases: - /docs/oncall/latest/calendar-schedules/ canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ -description: "" +description: "Learn more about on-call schedules" keywords: - Grafana - oncall - - on-call + - schedule - calendar -title: Configure and manage on-call schedules weight: 1100 --- -# Configure and manage on-call schedules +# On-call schedules -Grafana OnCall allows you to use any calendar service that uses the iCal format to create customized on-call schedules -for team members. Using Grafana OnCall, you can create a primary calendar that acts as a read-only schedule, and an -override calendar that allows all team members to modify schedules as they change. +Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts don’t go unnoticed. Use Grafana OnCall to: +- Define coverage needs and avoid gaps in coverage +- Automate alert escalation +- Configure on-call shift notifications -To learn more about creating on-call calendars, see the following topics: +This section provides conceptual information about Grafana OnCall schedule options. -## Configure and manage on-call schedules +## About on-call schedules -You can use any calendar with an iCal address to schedule on-call times for users. During these times, notifications -configured in escalation chains with the **Notify users from an on-call schedule** setting will be sent to the the person -scheduled. You can also schedule multiple users for overlapping times, and assign prioritization labels for the user -that you would like to notify. +An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the corresponding escalation chain for alert notifications to be sent to an on-call user. -When you create a schedule, you will be able to select a Slack channel, associated with your OnCall account, that will -notify users when there are errors or notifications regarding the assigned on-call shifts. +A fully configured on-call schedule consists of three main components: -## Create an on-call schedule calendar +- **Rotations**: A recurring schedule containing a set of on-call shifts that users rotate through. +- **On-call shifts**: The period of time that an individual user is on-call for a particular rotation +- **Escalation Chains**: Automated steps that determine who to notify of an alert group. -Create a primary calendar and an optional override calendar to schedule on-call shifts for team members. -1. In the **Scheduling** section of Grafana OnCall, click **+ Create schedule**. -1. Give the schedule a name. -1. Create a new calendar in your calendar service and locate the secret iCal URL. For example, in a Google calendar, - this URL can be found in **Settings > Settings for my calendars > Integrate calendar**. -1. Copy the secret iCal URL. In OnCall, paste it into the **Primary schedule for iCal URL** field. - The permissions you set when you create the calendar determine who can modify the calendar. -1. Click **Create Schedule**. -1. Schedule on-call times for team members. +## Types of on-call schedules +On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different options for managing your on-call schedules, so you can choose the option that best fits your needs. - Use the Grafana username of team members as the event name to schedule their on-call times. You can take advantage - of all of the features of your calendar service. +### Web-based schedule +Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations, see teammates' time zones, and add overrides. -1. Create overlapping schedules (optional). +Learn more about [Web-based schedules]({{< relref "web-schedule" >}}) - When you create schedules that overlap, you can prioritize a schedule by adding a level marker. For example, if users - AliceGrafana and BobGrafana have overlapping schedules, but BobGrafana is the primary contact, you would name his - event `[L1] BobGrafana`, AliceGrafana maintains the default `[L0]` status, and would not receive notifications during - the overlapping time. You can prioritize up to and including a level 9 prioritization, or `[L9]`. +### iCal import +Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. -# Create an override calendar (optional) +Learn more about [iCal import schedules]({{< relref "ical-schedules" >}}) -You can use an override calendar to allow team members to schedule on-call duties that will override the primary schedule. -An override option allows flexibility without modifying the primary schedule. Events scheduled on the override calendar -will always override overlapping events on the primary calendar. +### Terraform +Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules are read-only and cannot be edited from the UI. -1. Create a new calendar using the same calendar service you used to create the primary calendar. - - Be sure to set permissions that allow team members to edit the calendar. - -1. In the scheduling section of Grafana OnCall, select the primary calendar you want to override. -1. Click **Edit**. -1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**. +To learn more, read our [Get started with Grafana OnCall and Terraform](https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post. \ No newline at end of file diff --git a/docs/sources/calendar-schedules/ical-schedules/index.md b/docs/sources/calendar-schedules/ical-schedules/index.md new file mode 100644 index 00000000..0950e51e --- /dev/null +++ b/docs/sources/calendar-schedules/ical-schedules/index.md @@ -0,0 +1,79 @@ +--- +title: Import on-call schedules +aliases: + - /docs/oncall/latest/calendar-schedules/ical-schedules/ +canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/ical-schedules/ +description: "Learn how to manage on-call schedules with iCal import" +keywords: + - Grafana + - oncall + - on-call + - calendar +weight: 300 +--- + +# Import on-call schedules + +Use your existing calendar app with iCal format to manage and customize on-call schedules — import rotations and shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. + +## Before you begin + +- Verify that your calendar app supports iCal format +- Ensure you have the proper permissions in Grafana OnCall + +## Configure an on-call schedule from iCal import + +There are three key parts to configuring on-call schedules using iCal import: +1. Create a primary on-call calendar and an optional override calendar in your calendar app. +1. Import the calendars into Grafana OnCall and configure additional schedule settings. +1. Link your schedule to corresponding escalation chains for alert notifications to be sent to the proper on-call user. + +### Create your on-call schedule calendar + +Create a dedicated calendar to map out your on-call coverage using calendar events. Be sure to take advantage of the features of your calendar app to configure event recurrence, duplicate events, etc. + +>**Note:** The exact steps in this section will vary based on your calendar. + +To create an on-call schedule calendar: + +1. Create a new calendar in your calendar app, then review and adjust default settings as needed. +1. In your new calendar, create events that represent on-call shifts. You must use Grafana usernames as the event title to associate users with each shift. +1. Once your on-call calendar is complete, go to your calendar settings to locate the secret iCal URL. For example, in a Google calendar, this URL can be found in **Settings** > **Settings for my calendars** > **Integrate calendar** > **Secret address in iCal format**. + +To learn more about how to configure your calendar events, refer to Calendar events. + +### Import calendar to Grafana On-Call + +Once you’ve configured on-call schedules in your calendar app, you can import them via iCal URL to your Grafana OnCall instance. + +>**Note:** Use the secret iCal URL to avoid making the calendar public. If you use the public iCal URL, the calendar and event details must be public for Grafana OnCall to read your calendar. + +To import an on-call schedule: + +1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**. +1. Navigate to **Import schedule from iCal URL** and click **+ Create**. +1. Copy the secret iCal URL from your calendar and paste it the **Primary schedule iCal URL** field. Repeat this step for the **Override schedule iCal URL** field if you have an override calendar. +1. Provide a name and review available schedule settings. +1. When you’re done, click **Create Schedule**. + + +### Create an override calendar (Optional) + +An override calendar allows for on-call flexibility without modifying the primary schedule. You can use an override calendar to enable users to schedule on-call shifts that will override the primary schedule. Events scheduled on the override calendar will always override overlapping events on the primary calendar. + +1. Create a new calendar using the same calendar service you used to create the primary calendar. +1. Be sure to set permissions that allow team members to edit the calendar. +1. In the **Schedules** tab of Grafana OnCall, select the primary calendar you want to override.Click **Edit**. +1. Enter the secret iCal URL in the **Overrides schedule iCal URL** field and click **Update**. + +## Calendar events + +Whether your schedule is basic or complex, consider how your on-call coverage is structured before configuring your calendar events. To minimize the number of calendar events you need to create, try leveraging recurrence settings and event duplication. + +> **Note:** Each calendar event represents one on-call shift for a specific user. For Grafana OnCall to associate a calendar event with the intended on-call user, you must use their Grafana username as the event title. + +### Create overlapping schedules (optional) + +If you create schedules that overlap, you can prioritize a schedule by adding a level marker to the calendar event title. You can prioritize schedule overlaps using [L0] - [L9] prioritization. Overlapping calendar events that do not contain a level marker result in all overlapping users receiving notifications. + +For example, users AliceGrafana and BobGrafana have overlapping schedules but BobGrafana is the intended primary contact. The calendar events titles would be `[L1] BobGrafana` and `[L0] AliceGrafana` - In this case AliceGrafana maintains the default [L0] status, and would not receive notifications during the overlapping time with BobGrafana. diff --git a/docs/sources/calendar-schedules/web-schedule/_index.md b/docs/sources/calendar-schedules/web-schedule/_index.md new file mode 100644 index 00000000..aecf3b0a --- /dev/null +++ b/docs/sources/calendar-schedules/web-schedule/_index.md @@ -0,0 +1,53 @@ +--- +title: Web-based schedules +aliases: + - /docs/oncall/latest/calendar-schedules/web-schedule/ +canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/ +description: "Learn more about Grafana OnCalls built in schedule tool" +keywords: + - Grafana + - oncall + - schedule + - calendar +weight: 100 +--- + +# About web-based schedules + +Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize rotations with a live schedule preview, reference teammates' time zones, and add overrides. + +This topic provides an overview of key components and features. + +For information on how to create a schedule in Grafana OnCall, refer to [Create an on-call schedule]({{< relref "create-schedule" >}}) + +>**Note**: User permissions determine which components of Grafana OnCall are available to you. + + +## Schedule settings + +Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking the gear icon next to an existing schedule. + +Available schedule settings: + +- **Slack channel:** Choose a primary Slack channel to send notifications about on-call shifts, such as unassigned on-call shifts. +- **Slack user group:** Choose a Slack user group to receive current on-call updates. +- **Notification frequency:** Specify whether or not to send shift notifications to scheduled team members. +- **Action for slot when no one is on-call:** Define how your team is notified when an empty shift causes a gap in on-call coverage. +- **Current shift notification settings:** Select how users are notified when their on-call shift begins. +- **Next shift notification settings:** Specify how users are notified of upcoming shifts. + +## Schedule view + +The schedule view is a detailed calendar representation of your on-call schedule. It contains three interactive weekly calendars and a 24-hour on-call status bar for visualizing who’s on-call and what time it is for your teammates. + +Understand your schedule view: + +- **Final schedule:** The final schedule provides a combined view of rotations and overrides +- **Rotations:** The rotations calendar represents all recurring on-call rotations for a given schedule. +- **Overrides:** The override calendar represents temporary adjustments to the recurring on-call schedule. Any events on this calendar will take precedence over the rotations calendar. + +## Schedule export + +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. + +For more information, refer to [Export on-call schedules]({{< relref "calendar-export" >}}) diff --git a/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md b/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md new file mode 100644 index 00000000..dda4d0bd --- /dev/null +++ b/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md @@ -0,0 +1,67 @@ +--- +title: Export on-call schedules +aliases: + - /docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/ +canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/calendar-export/ +description: "Learn how to export an on-call schedule from Grafana OnCall" +keywords: + - Grafana + - oncall + - on-call + - calendar + - iCal export +weight: 500 +--- + +# Export on-call schedules + +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 add on-call schedules to your existing calendar to view on-call shifts alongside the rest of your schedule. + +There are two schedule export options available: + +- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides, and assigned users. +- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to add your assigned on-call shifts to your calendar. + +> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden. + +## Export an on-call schedule + +Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared calendars. + +To export a schedule from Grafana OnCall: + +1. In Grafana OnCall, navigate to the **Schedules** tab. +1. Open the schedule you’d like to export by clicking on the schedule name. +1. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL. +1. Copy the iCal link and store it somewhere you’ll remember. Once you close the schedule export window, you won't be able to access the iCal link. +1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). + +## Export a user on-call schedule +Use this export option to add your assigned on-call shifts to your calendar. Best for personal calendars. + +To export your on-call schedule: + +1. In Grafana OnCall, navigate to the **Users** tab. +1. Click **View my profile** in the upper right corner. +1. From the **User Info** tab, navigate to the iCal link section. +1. Click **+ Create iCal link** to generate your secret iCal URL. +1. Copy the iCal link and store it somewhere you’ll remember. Once you close your user profile, you won't be able to access the iCal link again. +1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). + +## Revoke an iCal export link + +iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference. To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and cannot be accessed again. + +If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link will lose access to the calendar data. + +[comment]: <> (>**Note**: Use caution when revoking an iCal link associated with shared on-call schedules. If you aren't the creator of the existing iCal link, check with your teammates before revoking it.) + +To revoke an active iCal link: + +1. Navigate to the schedule or user profile associated with the iCal link. +1. For schedules, click **Export** to open the Schedule export window. +1. For users, navigate to the iCal link section of the **User info** tab. +1. If there is an active iCal link, click **Revoke iCal link**. +1. Once revoked, you can generate a new iCal link by clicking **+ Create iCal link**. + + diff --git a/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md b/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md new file mode 100644 index 00000000..ebd52339 --- /dev/null +++ b/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md @@ -0,0 +1,69 @@ +--- +title: Create on-call schedules +aliases: + - /docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/ +canonical: https://grafana.com/docs/oncall/latest/calendar-schedules/web-schedule/create-schedule/ +description: "Create on-call schedules with Grafana OnCall" +keywords: + - Grafana + - oncall + - on-call + - schedule + - calendar +weight: 300 +--- + +# Create on-call schedules in Grafana OnCall + +Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize your schedule, add users, reorder users, and reference teammates' time zones. + +To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental concepts for this task. + +## Before you begin + +- Users with Admin or Editor roles can create, edit and delete schedules. +- Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call. + +For more information about permissions, refer to [Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}}) + +## Create an on-call schedule + +To create a new on-call schedule: + +1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule** +1. Navigate to **Set up on-call rotation schedule** and click **+ Create** +1. Provide a name and review available schedule settings +1. When you’re done, click **Create Schedule** + +>**Note:** You can edit your schedule settings at any time. + +### Add a rotation to your on-call schedule +After creating your schedule, you can add rotations to build out your coverage needs. +Think of a rotation as a recurring schedule containing on-call shifts that users rotate through. + +To add a rotation to an on-call schedule: + +1. From your newly created schedule, click **+ Add rotation** and select **New Layer**. +1. Complete the rotation creation form according to your rotation parameters. +1. Add users to the rotation from the dropdown. +You can separate users into user groups to rotate through individual users per shift. User groups that contain multiple users results in all users in the group being included in corresponding shifts. +1. When you’re satisfied with the rotation preview, click **Create**. + + +[comment]: <> (For further instruction on rotation configuration, refer to Manage and configure rotations) + +### Add an on-call schedule to escalation chains + +Now that you’ve created your schedule, it must be referenced in the steps of an escalation chain for on-call users to receive alert notifications. + +To connect a schedule to an escalation chain: + +1. In Grafana OnCall, go to the **Escalation Chains** tab. +1. Navigate to an existing escalation chain or click **+ New Escalation Chain**. +1. Select **Notify users from on-call schedule** from the **Add escalation step** dropdown. +1. Specify which notification policy to use and the appropriate schedule. +1. Click and drag the escalation steps to reorder, if needed. + +Escalation chain steps are saved automatically. + +For more information about Escalation Chains, refer to [Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}}) \ No newline at end of file From cfa7fb816c53c3d8626027753696873289356fa8 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 24 Jan 2023 13:44:07 +0800 Subject: [PATCH 26/33] Sync users and teams on tf requests (#1180) # What this PR does This PR add sync with grafana on requests from terraform ## Which issue(s) this PR fixes It's needed to fix case when customers want to create team via grafana terraform provider and use it in the oncall provider without having to log into Grafana Cloud. Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + engine/apps/grafana_plugin/helpers/client.py | 25 ++--- engine/apps/grafana_plugin/tasks/__init__.py | 6 +- engine/apps/grafana_plugin/tasks/sync.py | 15 ++- engine/apps/public_api/tf_sync.py | 35 +++++++ engine/apps/public_api/views/teams.py | 3 + engine/apps/public_api/views/users.py | 3 + engine/apps/user_management/sync.py | 33 +++++-- .../apps/user_management/tests/test_sync.py | 98 +++++++++++++++++-- .../oncall_gateway/oncall_gateway_client.py | 3 - 10 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 engine/apps/public_api/tf_sync.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e96543..8beadadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Slack slash command allowing to trigger a direct page via a manually created alert group +- Add sync with grafana on /users and /teams api calls from terraform plugin ### Changed diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index b742d92c..edad4812 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -54,16 +54,16 @@ class APIClient: self.api_url = api_url self.api_token = api_token - def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.head, body) + def api_head(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.head, body, **kwargs) - def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.get) + def api_get(self, endpoint: str, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.get, **kwargs) - def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.post, body) + def api_post(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.post, body, **kwargs) - def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]: + def call_api(self, endpoint: str, http_method, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: request_start = time.perf_counter() call_status = { "url": urljoin(self.api_url, endpoint), @@ -72,7 +72,7 @@ class APIClient: "message": "", } try: - response = http_method(call_status["url"], json=body, headers=self.request_headers) + response = http_method(call_status["url"], json=body, headers=self.request_headers, **kwargs) call_status["status_code"] = response.status_code response.raise_for_status() @@ -89,6 +89,7 @@ class APIClient: requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.TooManyRedirects, + requests.exceptions.Timeout, json.JSONDecodeError, ) as e: logger.warning("Error connecting to api instance " + str(e)) @@ -153,8 +154,8 @@ class GrafanaAPIClient(APIClient): _, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT) return resp_status["status_code"] == status.HTTP_200_OK - def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]: - users, _ = self.api_get("api/org/users") + def get_users(self, rbac_is_enabled_for_org: bool, **kwargs) -> List[GrafanaUserWithPermissions]: + users, _ = self.api_get("api/org/users", **kwargs) if not users: return [] @@ -166,8 +167,8 @@ class GrafanaAPIClient(APIClient): user["permissions"] = user_permissions.get(str(user["userId"]), []) return users - def get_teams(self): - return self.api_get("api/teams/search?perpage=1000000") + def get_teams(self, **kwargs): + return self.api_get("api/teams/search?perpage=1000000", **kwargs) def get_team_members(self, team_id): return self.api_get(f"api/teams/{team_id}/members") diff --git a/engine/apps/grafana_plugin/tasks/__init__.py b/engine/apps/grafana_plugin/tasks/__init__.py index 8ba4f62e..049a25bb 100644 --- a/engine/apps/grafana_plugin/tasks/__init__.py +++ b/engine/apps/grafana_plugin/tasks/__init__.py @@ -1 +1,5 @@ -from .sync import start_sync_organizations, sync_organization_async # noqa: F401 +from .sync import ( # noqa: F401 + start_sync_organizations, + sync_organization_async, + sync_team_members_for_organization_async, +) diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index 204797d9..6c205d7b 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -5,10 +5,11 @@ from django.conf import settings from django.utils import timezone from apps.grafana_plugin.helpers import GcomAPIClient +from apps.grafana_plugin.helpers.client import GrafanaAPIClient from apps.grafana_plugin.helpers.gcom import get_active_instance_ids, get_deleted_instance_ids, get_stack_regions from apps.user_management.models import Organization from apps.user_management.models.region import sync_regions -from apps.user_management.sync import cleanup_organization, sync_organization +from apps.user_management.sync import cleanup_organization, sync_organization, sync_team_members from common.custom_celery_tasks import shared_dedicated_queue_retry_task logger = get_task_logger(__name__) @@ -117,3 +118,15 @@ def start_sync_regions(): return sync_regions(regions) + + +@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1) +def sync_team_members_for_organization_async(organization_pk): + try: + organization = Organization.objects.get(pk=organization_pk) + except Organization.DoesNotExist: + logger.info(f"Organization {organization_pk} was not found") + return + + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + sync_team_members(grafana_api_client, organization) diff --git a/engine/apps/public_api/tf_sync.py b/engine/apps/public_api/tf_sync.py new file mode 100644 index 00000000..dfde226f --- /dev/null +++ b/engine/apps/public_api/tf_sync.py @@ -0,0 +1,35 @@ +import logging + +from django.core.cache import cache + +from apps.grafana_plugin.helpers.client import GrafanaAPIClient +from apps.grafana_plugin.tasks import sync_team_members_for_organization_async +from apps.user_management.sync import sync_teams, sync_users + +logger = logging.getLogger(__name__) + +SYNC_REQUEST_TIMEOUT = 5 +SYNC_PERIOD = 60 + + +def is_request_from_terraform(request) -> bool: + return "terraform-provider-grafana" in request.META.get("HTTP_USER_AGENT", "") + + +def sync_users_on_tf_request(organization): + cache_key = f"sync_users_on_tf_request_{organization.id}" + if not cache.get(cache_key): + logger.info(f"Start sync_users_on_tf_request organization_id={organization.id}") + client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + cache.set(cache_key, True, SYNC_PERIOD) + sync_users(client, organization, timeout=SYNC_REQUEST_TIMEOUT) + + +def sync_teams_on_tf_request(organization): + cache_key = f"sync_teams_on_tf_request_{organization.id}" + if not cache.get(cache_key): + logger.info(f"Start sync_teams_on_tf_request organization_id={organization.id}") + cache.set(cache_key, True, SYNC_PERIOD) + client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + sync_teams(client, organization, timeout=SYNC_REQUEST_TIMEOUT) + sync_team_members_for_organization_async.apply_async((organization.id,)) diff --git a/engine/apps/public_api/views/teams.py b/engine/apps/public_api/views/teams.py index c42e0460..40eb0e87 100644 --- a/engine/apps/public_api/views/teams.py +++ b/engine/apps/public_api/views/teams.py @@ -4,6 +4,7 @@ from rest_framework.permissions import IsAuthenticated from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.teams import TeamSerializer +from apps.public_api.tf_sync import is_request_from_terraform, sync_teams_on_tf_request from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.models import Team from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -20,6 +21,8 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewse throttle_classes = [UserThrottle] def get_queryset(self): + if is_request_from_terraform(self.request): + sync_teams_on_tf_request(self.request.auth.organization) name = self.request.query_params.get("name", None) queryset = self.request.auth.organization.teams.all() if name: diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 1eed6e1d..bba5484e 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -9,6 +9,7 @@ from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer +from apps.public_api.tf_sync import is_request_from_terraform, sync_users_on_tf_request from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.ical_utils import user_ical_export from apps.schedules.models import OnCallSchedule @@ -48,6 +49,8 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet throttle_classes = [UserThrottle] def get_queryset(self): + if is_request_from_terraform(self.request): + sync_users_on_tf_request(self.request.auth.organization) is_short_request = self.request.query_params.get("short", "false") == "true" queryset = self.request.auth.organization.users.all() if not is_short_request: diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 9d2b907d..01dfabf8 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -30,11 +30,11 @@ def sync_organization(organization): _sync_instance_info(organization) - api_users = grafana_api_client.get_users(rbac_is_enabled) - - if api_users: + _, check_token_call_status = grafana_api_client.check_token() + if check_token_call_status["status_code"] == 200: organization.api_token_status = Organization.API_TOKEN_STATUS_OK - sync_users_and_teams(grafana_api_client, api_users, organization) + sync_users_and_teams(grafana_api_client, organization) + organization.last_time_synced = timezone.now() organization.is_grafana_incident_enabled = check_grafana_incident_is_enabled(grafana_api_client) else: organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED @@ -71,27 +71,42 @@ def _sync_instance_info(organization): organization.gcom_token_org_last_time_synced = timezone.now() -def sync_users_and_teams(client, api_users, organization): +def sync_users_and_teams(client: GrafanaAPIClient, organization): + sync_users(client, organization) + sync_teams(client, organization) + sync_team_members(client, organization) + + +def sync_users(client: GrafanaAPIClient, organization, **kwargs): + api_users = client.get_users(organization.is_rbac_permissions_enabled, **kwargs) # check if api_users are shaped correctly. e.g. for paused instance, the response is not a list. if not api_users or not isinstance(api_users, (tuple, list)): return - User.objects.sync_for_organization(organization=organization, api_users=api_users) - api_teams_result, _ = client.get_teams() + +def sync_teams(client: GrafanaAPIClient, organization, **kwargs): + api_teams_result, _ = client.get_teams(**kwargs) if not api_teams_result: return - api_teams = api_teams_result["teams"] Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) + +def sync_team_members(client: GrafanaAPIClient, organization): for team in organization.teams.all(): members, _ = client.get_team_members(team.team_id) if not members: continue User.objects.sync_for_team(team=team, api_members=members) - organization.last_time_synced = timezone.now() + +def sync_users_for_teams(client: GrafanaAPIClient, organization, **kwargs): + api_teams_result, _ = client.get_teams(**kwargs) + if not api_teams_result: + return + api_teams = api_teams_result["teams"] + Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) def check_grafana_incident_is_enabled(client): diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 40fc1223..2b9dddd5 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -134,14 +134,19 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat }, ) + api_check_token_call_status = {"status_code": 200} + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False): with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): with patch.object( - GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status) ): - sync_organization(organization) + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) # check that users are populated assert organization.users.count() == 1 @@ -167,9 +172,50 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat def test_sync_organization_is_rbac_permissions_enabled_open_source(make_organization, grafana_api_response): organization = make_organization() + api_users_response = ( + { + "userId": 1, + "email": "test@test.test", + "name": "Test", + "login": "test", + "role": "admin", + "avatarUrl": "test.test/test", + "permissions": [], + }, + ) + + api_teams_response = { + "totalCount": 1, + "teams": ( + { + "id": 1, + "name": "Test", + "email": "test@test.test", + "avatarUrl": "test.test/test", + }, + ), + } + + api_members_response = ( + { + "orgId": organization.org_id, + "teamId": 1, + "userId": 1, + }, + ) + api_check_token_call_status = {"status_code": 200} + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=grafana_api_response): - with patch.object(GrafanaAPIClient, "get_users", return_value=[]): - sync_organization(organization) + with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): + with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): + with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): + with patch.object( + GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status) + ): + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) organization.refresh_from_db() assert organization.is_rbac_permissions_enabled == grafana_api_response @@ -184,10 +230,50 @@ def test_sync_organization_is_rbac_permissions_enabled_cloud(mocked_gcom_client, stack_id = 5 organization = make_organization(stack_id=stack_id) + api_check_token_call_status = {"status_code": 200} + mocked_gcom_client.return_value.is_rbac_enabled_for_stack.return_value = gcom_api_response - with patch.object(GrafanaAPIClient, "get_users", return_value=[]): - sync_organization(organization) + api_users_response = ( + { + "userId": 1, + "email": "test@test.test", + "name": "Test", + "login": "test", + "role": "admin", + "avatarUrl": "test.test/test", + "permissions": [], + }, + ) + + api_teams_response = { + "totalCount": 1, + "teams": ( + { + "id": 1, + "name": "Test", + "email": "test@test.test", + "avatarUrl": "test.test/test", + }, + ), + } + + api_members_response = ( + { + "orgId": organization.org_id, + "teamId": 1, + "userId": 1, + }, + ) + + with patch.object(GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)): + with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): + with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): + with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) organization.refresh_from_db() diff --git a/engine/common/oncall_gateway/oncall_gateway_client.py b/engine/common/oncall_gateway/oncall_gateway_client.py index 77af439d..9b8c03b5 100644 --- a/engine/common/oncall_gateway/oncall_gateway_client.py +++ b/engine/common/oncall_gateway/oncall_gateway_client.py @@ -129,11 +129,8 @@ class OnCallGatewayAPIClient: if response.status_code not in [200, 201, 202, 204]: err_msg = cls._get_error_msg_from_response(response) if 400 <= response.status_code < 500: - print(1) err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url) - elif 500 <= response.status_code < 600: - print(2) err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url) print(err_msg) raise requests.exceptions.HTTPError(err_msg, response=response) From e5643fee0a5d0ef0a09eb24ef1dffd70d722e755 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 24 Jan 2023 06:51:37 +0000 Subject: [PATCH 27/33] Add check for MANIFEST.txt after signing (#1198) Add a check in drone so build fails if plugin is not successfully signed. Co-authored-by: Joey Orlando --- .drone.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 9b689d3e..3456e3e4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -27,6 +27,7 @@ steps: - apt-get install zip - cd grafana-plugin - yarn sign + - if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi - yarn ci-build:finish - yarn ci-package - cd ci/dist @@ -194,6 +195,7 @@ steps: - apt-get install zip - cd grafana-plugin - yarn sign + - if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi - yarn ci-build:finish - yarn ci-package - cd ci/dist @@ -415,6 +417,6 @@ kind: secret name: drone_token --- kind: signature -hmac: f77d17560f910f1a99ab8230674dc25c226d2b3c73cb90e63e53fb8ba760d57a +hmac: 662c2be2ccdd106ae4f23a557f981ef601d9693b0333e0bcda7189ddf16fb49a ... From 615a2e8333212b51480e92c4a987ad996a651e78 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Tue, 24 Jan 2023 17:58:38 +0800 Subject: [PATCH 28/33] Fix markdown formatting in docs (#1202) # What this PR does This PR adds `--fix` flag to `markdownlint` pre-commit command and fixes existing formatting to comply with markdown formatting rules. ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [x] Documentation added - [ ] `CHANGELOG.md` updated --- .pre-commit-config.yaml | 4 +- docs/sources/calendar-schedules/_index.md | 35 ++++++++---- .../ical-schedules/index.md | 54 ++++++++++++------- .../calendar-schedules/web-schedule/_index.md | 28 ++++++---- .../web-schedule/calendar-export/index.md | 51 ++++++++++-------- .../web-schedule/create-schedule/index.md | 37 +++++++------ 6 files changed, 130 insertions(+), 79 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75286e02..7e427978 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,7 +105,7 @@ repos: hooks: - id: markdownlint name: markdownlint - entry: markdownlint --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md + entry: markdownlint --fix --ignore grafana-plugin/node_modules --ignore grafana-plugin/dist --ignore docs **/*.md - id: markdownlint name: markdownlint - docs - entry: markdownlint -c ./docs/.markdownlint.json ./docs/**/*.md + entry: markdownlint --fix -c ./docs/.markdownlint.json ./docs/**/*.md diff --git a/docs/sources/calendar-schedules/_index.md b/docs/sources/calendar-schedules/_index.md index 201aa5de..bde33f23 100644 --- a/docs/sources/calendar-schedules/_index.md +++ b/docs/sources/calendar-schedules/_index.md @@ -14,38 +14,51 @@ weight: 1100 # On-call schedules -Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts don’t go unnoticed. Use Grafana OnCall to: +Grafana OnCall makes it easier to establish consistent and thoughtful on-call coverage while ensuring that alerts don’t +go unnoticed. Use Grafana OnCall to: + - Define coverage needs and avoid gaps in coverage - Automate alert escalation -- Configure on-call shift notifications +- Configure on-call shift notifications This section provides conceptual information about Grafana OnCall schedule options. ## About on-call schedules -An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the corresponding escalation chain for alert notifications to be sent to an on-call user. +An on-call schedule consist of one or more rotations that contain on-call shifts. A schedule must be referenced in the +corresponding escalation chain for alert notifications to be sent to an on-call user. -A fully configured on-call schedule consists of three main components: +A fully configured on-call schedule consists of three main components: - **Rotations**: A recurring schedule containing a set of on-call shifts that users rotate through. - **On-call shifts**: The period of time that an individual user is on-call for a particular rotation -- **Escalation Chains**: Automated steps that determine who to notify of an alert group. - +- **Escalation Chains**: Automated steps that determine who to notify of an alert group. ## Types of on-call schedules -On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different options for managing your on-call schedules, so you can choose the option that best fits your needs. + +On-call schedules look different for different organizations and even teams. Grafana OnCall offers three different +options for managing your on-call schedules, so you can choose the option that best fits your needs. ### Web-based schedule -Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations, see teammates' time zones, and add overrides. + +Configure and manage on-call schedules directly in the Grafana OnCall plugin. Easily configure and preview rotations, +see teammates' time zones, and add overrides. Learn more about [Web-based schedules]({{< relref "web-schedule" >}}) ### iCal import -Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. + +Use any calendar service that uses the iCal format to manage and customize on-call schedules - Import rotations and +shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imports appear in Grafana +OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. Learn more about [iCal import schedules]({{< relref "ical-schedules" >}}) ### Terraform -Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules are read-only and cannot be edited from the UI. -To learn more, read our [Get started with Grafana OnCall and Terraform](https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post. \ No newline at end of file +Use the Grafana OnCall Terraform provider to manage schedules within your “as-code” workflow. Rotations configured +via Terraform are automatically added to your schedules in Grafana OnCall. Similar to the iCal import, these schedules +read-only and cannot be edited from the UI. + +To learn more, read our [Get started with Grafana OnCall and Terraform]( +https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post. diff --git a/docs/sources/calendar-schedules/ical-schedules/index.md b/docs/sources/calendar-schedules/ical-schedules/index.md index 0950e51e..47aea0ba 100644 --- a/docs/sources/calendar-schedules/ical-schedules/index.md +++ b/docs/sources/calendar-schedules/ical-schedules/index.md @@ -14,52 +14,63 @@ weight: 300 # Import on-call schedules -Use your existing calendar app with iCal format to manage and customize on-call schedules — import rotations and shifts from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. +Use your existing calendar app with iCal format to manage and customize on-call schedules — import rotations and shifts +from your calendar app to Grafana OnCall for widely accessible scheduling. iCal imported schedules appear in Grafana +OnCall as read-only schedules but can be leveraged similarly to a web-based schedule. ## Before you begin -- Verify that your calendar app supports iCal format +- Verify that your calendar app supports iCal format - Ensure you have the proper permissions in Grafana OnCall ## Configure an on-call schedule from iCal import There are three key parts to configuring on-call schedules using iCal import: + 1. Create a primary on-call calendar and an optional override calendar in your calendar app. 1. Import the calendars into Grafana OnCall and configure additional schedule settings. 1. Link your schedule to corresponding escalation chains for alert notifications to be sent to the proper on-call user. ### Create your on-call schedule calendar -Create a dedicated calendar to map out your on-call coverage using calendar events. Be sure to take advantage of the features of your calendar app to configure event recurrence, duplicate events, etc. +Create a dedicated calendar to map out your on-call coverage using calendar events. Be sure to take advantage of the +features of your calendar app to configure event recurrence, duplicate events, etc. >**Note:** The exact steps in this section will vary based on your calendar. To create an on-call schedule calendar: -1. Create a new calendar in your calendar app, then review and adjust default settings as needed. -1. In your new calendar, create events that represent on-call shifts. You must use Grafana usernames as the event title to associate users with each shift. -1. Once your on-call calendar is complete, go to your calendar settings to locate the secret iCal URL. For example, in a Google calendar, this URL can be found in **Settings** > **Settings for my calendars** > **Integrate calendar** > **Secret address in iCal format**. +1. Create a new calendar in your calendar app, then review and adjust default settings as needed. +2. In your new calendar, create events that represent on-call shifts. You must use Grafana usernames as the event title +3. to associate users with each shift. +4. Once your on-call calendar is complete, go to your calendar settings to locate the secret iCal URL. For example, in +5. a Google calendar, this URL can be found in **Settings** > **Settings for my calendars** > **Integrate calendar** > +6. **Secret address in iCal format**. To learn more about how to configure your calendar events, refer to Calendar events. ### Import calendar to Grafana On-Call -Once you’ve configured on-call schedules in your calendar app, you can import them via iCal URL to your Grafana OnCall instance. +Once you’ve configured on-call schedules in your calendar app, you can import them via iCal URL to your Grafana OnCall +instance. ->**Note:** Use the secret iCal URL to avoid making the calendar public. If you use the public iCal URL, the calendar and event details must be public for Grafana OnCall to read your calendar. +>**Note:** Use the secret iCal URL to avoid making the calendar public. If you use the public iCal URL, the calendar +> and event details must be public for Grafana OnCall to read your calendar. To import an on-call schedule: 1. In Grafana OnCall, navigate to the **Schedules** tab and click **+ New schedule**. -1. Navigate to **Import schedule from iCal URL** and click **+ Create**. -1. Copy the secret iCal URL from your calendar and paste it the **Primary schedule iCal URL** field. Repeat this step for the **Override schedule iCal URL** field if you have an override calendar. -1. Provide a name and review available schedule settings. -1. When you’re done, click **Create Schedule**. - +2. Navigate to **Import schedule from iCal URL** and click **+ Create**. +3. Copy the secret iCal URL from your calendar and paste it the **Primary schedule iCal URL** field. Repeat this step +4. for the **Override schedule iCal URL** field if you have an override calendar. +5. Provide a name and review available schedule settings. +6. When you’re done, click **Create Schedule**. ### Create an override calendar (Optional) -An override calendar allows for on-call flexibility without modifying the primary schedule. You can use an override calendar to enable users to schedule on-call shifts that will override the primary schedule. Events scheduled on the override calendar will always override overlapping events on the primary calendar. +An override calendar allows for on-call flexibility without modifying the primary schedule. You can use an override +calendar to enable users to schedule on-call shifts that will override the primary schedule. Events scheduled on the +override calendar will always override overlapping events on the primary calendar. 1. Create a new calendar using the same calendar service you used to create the primary calendar. 1. Be sure to set permissions that allow team members to edit the calendar. @@ -68,12 +79,19 @@ An override calendar allows for on-call flexibility without modifying the primar ## Calendar events -Whether your schedule is basic or complex, consider how your on-call coverage is structured before configuring your calendar events. To minimize the number of calendar events you need to create, try leveraging recurrence settings and event duplication. +Whether your schedule is basic or complex, consider how your on-call coverage is structured before configuring your +calendar events. To minimize the number of calendar events you need to create, try leveraging recurrence settings and +event duplication. -> **Note:** Each calendar event represents one on-call shift for a specific user. For Grafana OnCall to associate a calendar event with the intended on-call user, you must use their Grafana username as the event title. +> **Note:** Each calendar event represents one on-call shift for a specific user. For Grafana OnCall to associate a +> calendar event with the intended on-call user, you must use their Grafana username as the event title. ### Create overlapping schedules (optional) -If you create schedules that overlap, you can prioritize a schedule by adding a level marker to the calendar event title. You can prioritize schedule overlaps using [L0] - [L9] prioritization. Overlapping calendar events that do not contain a level marker result in all overlapping users receiving notifications. +If you create schedules that overlap, you can prioritize a schedule by adding a level marker to the calendar event +title. You can prioritize schedule overlaps using [L0] - [L9] prioritization. Overlapping calendar events that do not +contain a level marker result in all overlapping users receiving notifications. -For example, users AliceGrafana and BobGrafana have overlapping schedules but BobGrafana is the intended primary contact. The calendar events titles would be `[L1] BobGrafana` and `[L0] AliceGrafana` - In this case AliceGrafana maintains the default [L0] status, and would not receive notifications during the overlapping time with BobGrafana. +For example, users AliceGrafana and BobGrafana have overlapping schedules but BobGrafana is the intended primary +contact. The calendar events titles would be `[L1] BobGrafana` and `[L0] AliceGrafana` - In this case AliceGrafana +maintains the default [L0] status, and would not receive notifications during the overlapping time with BobGrafana. diff --git a/docs/sources/calendar-schedules/web-schedule/_index.md b/docs/sources/calendar-schedules/web-schedule/_index.md index aecf3b0a..a47ecdc1 100644 --- a/docs/sources/calendar-schedules/web-schedule/_index.md +++ b/docs/sources/calendar-schedules/web-schedule/_index.md @@ -12,42 +12,50 @@ keywords: weight: 100 --- -# About web-based schedules +# About web-based schedules -Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize rotations with a live schedule preview, reference teammates' time zones, and add overrides. +Grafana OnCall allows you to map out recurring on-call coverage and automate the escalation of alert notifications to +on-call users. Configure and manage on-call schedules directly in the Grafana OnCall plugin to easily customize +rotations with a live schedule preview, reference teammates' time zones, and add overrides. This topic provides an overview of key components and features. -For information on how to create a schedule in Grafana OnCall, refer to [Create an on-call schedule]({{< relref "create-schedule" >}}) +For information on how to create a schedule in Grafana OnCall, refer to +[Create an on-call schedule]({{< relref "create-schedule" >}}) >**Note**: User permissions determine which components of Grafana OnCall are available to you. - ## Schedule settings -Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking the gear icon next to an existing schedule. +Schedule settings are initially configured when a new schedule is created and can be updated at any time by clicking +the gear icon next to an existing schedule. Available schedule settings: -- **Slack channel:** Choose a primary Slack channel to send notifications about on-call shifts, such as unassigned on-call shifts. +- **Slack channel:** Choose a primary Slack channel to send notifications about on-call shifts, such as unassigned +- on-call shifts. - **Slack user group:** Choose a Slack user group to receive current on-call updates. - **Notification frequency:** Specify whether or not to send shift notifications to scheduled team members. -- **Action for slot when no one is on-call:** Define how your team is notified when an empty shift causes a gap in on-call coverage. +- **Action for slot when no one is on-call:** Define how your team is notified when an empty shift causes a gap in +- on-call coverage. - **Current shift notification settings:** Select how users are notified when their on-call shift begins. - **Next shift notification settings:** Specify how users are notified of upcoming shifts. ## Schedule view -The schedule view is a detailed calendar representation of your on-call schedule. It contains three interactive weekly calendars and a 24-hour on-call status bar for visualizing who’s on-call and what time it is for your teammates. +The schedule view is a detailed calendar representation of your on-call schedule. It contains three interactive weekly +calendars and a 24-hour on-call status bar for visualizing who’s on-call and what time it is for your teammates. Understand your schedule view: - **Final schedule:** The final schedule provides a combined view of rotations and overrides - **Rotations:** The rotations calendar represents all recurring on-call rotations for a given schedule. -- **Overrides:** The override calendar represents temporary adjustments to the recurring on-call schedule. Any events on this calendar will take precedence over the rotations calendar. +- **Overrides:** The override calendar represents temporary adjustments to the recurring on-call schedule. Any events +- on this calendar will take precedence over the rotations calendar. ## Schedule export -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. +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. For more information, refer to [Export on-call schedules]({{< relref "calendar-export" >}}) diff --git a/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md b/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md index dda4d0bd..3057d99f 100644 --- a/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md +++ b/docs/sources/calendar-schedules/web-schedule/calendar-export/index.md @@ -15,53 +15,60 @@ weight: 500 # Export on-call schedules -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 add on-call schedules to your existing calendar to view on-call shifts alongside the rest of your schedule. +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 add on-call schedules to your existing calendar to view on-call shifts alongside the +rest of your schedule. There are two schedule export options available: -- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides, and assigned users. -- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to add your assigned on-call shifts to your calendar. +- **On-call schedule export** - Exports all on-call shifts for a particular schedule, including rotations, overrides, +- and assigned users. +- **User-specific schedule export** - Exports assigned on-call shifts for a particular user. Use this export option to +- add your assigned on-call shifts to your calendar. -> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden. +> **Note:** Calendar exports include all scheduled shifts, including those which are lower priority or overridden. ## Export an on-call schedule -Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared calendars. +Use this export option to add all on-call shifts associated with a schedule to a calendar. Best for a team or shared +calendars. To export a schedule from Grafana OnCall: 1. In Grafana OnCall, navigate to the **Schedules** tab. -1. Open the schedule you’d like to export by clicking on the schedule name. -1. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL. -1. Copy the iCal link and store it somewhere you’ll remember. Once you close the schedule export window, you won't be able to access the iCal link. -1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). +2. Open the schedule you’d like to export by clicking on the schedule name. +3. Click **Export** in the upper right corner, then click **+ Create iCal link** to generate a secret iCal URL. +4. Copy the iCal link and store it somewhere you’ll remember. Once you close the schedule export window, you won't be +5. able to access the iCal link. +6. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). ## Export a user on-call schedule + Use this export option to add your assigned on-call shifts to your calendar. Best for personal calendars. To export your on-call schedule: 1. In Grafana OnCall, navigate to the **Users** tab. -1. Click **View my profile** in the upper right corner. -1. From the **User Info** tab, navigate to the iCal link section. -1. Click **+ Create iCal link** to generate your secret iCal URL. -1. Copy the iCal link and store it somewhere you’ll remember. Once you close your user profile, you won't be able to access the iCal link again. -1. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). +2. Click **View my profile** in the upper right corner. +3. From the **User Info** tab, navigate to the iCal link section. +4. Click **+ Create iCal link** to generate your secret iCal URL. +5. Copy the iCal link and store it somewhere you’ll remember. Once you close your user profile, you won't be able to +6. access the iCal link again. +7. Open your calendar settings to add a calendar from a URL (This step varies based on your calendar app). -## Revoke an iCal export link +## Revoke an iCal export link -iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference. To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and cannot be accessed again. +iCal links are displayed upon creation, and users are advised to copy their link and store it for future reference. +To ensure the security of your and your teams' calendar data, after an iCal link is generated, the link is hidden and +cannot be accessed again. -If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link will lose access to the calendar data. - -[comment]: <> (>**Note**: Use caution when revoking an iCal link associated with shared on-call schedules. If you aren't the creator of the existing iCal link, check with your teammates before revoking it.) +If you need to revoke an iCal link, you can do so anytime. By doing so, any calendar that references the revoked link +will lose access to the calendar data. To revoke an active iCal link: 1. Navigate to the schedule or user profile associated with the iCal link. 1. For schedules, click **Export** to open the Schedule export window. 1. For users, navigate to the iCal link section of the **User info** tab. -1. If there is an active iCal link, click **Revoke iCal link**. +1. If there is an active iCal link, click **Revoke iCal link**. 1. Once revoked, you can generate a new iCal link by clicking **+ Create iCal link**. - - diff --git a/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md b/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md index ebd52339..5dbacf06 100644 --- a/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md +++ b/docs/sources/calendar-schedules/web-schedule/create-schedule/index.md @@ -15,16 +15,20 @@ weight: 300 # Create on-call schedules in Grafana OnCall -Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize your schedule, add users, reorder users, and reference teammates' time zones. - -To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental concepts for this task. +Schedules allow you to map out recurring on-call coverage and automate the escalation of alert notifications to +currently on-call users. With Grafana OnCall, you can customize rotations with a live schedule preview to visualize +your schedule, add users, reorder users, and reference teammates' time zones. + +To learn more, see [On-call schedules]({{< relref "../../../calendar-schedules" >}}) which provides the fundamental +concepts for this task. ## Before you begin - Users with Admin or Editor roles can create, edit and delete schedules. - Users with Viewer role cannot receive alert notifications, therefore, cannot be on-call. -For more information about permissions, refer to [Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}}) +For more information about permissions, refer to +[Manage users and teams for Grafana OnCall]({{< relref "../../../configure-user-settings" >}}) ## Create an on-call schedule @@ -35,26 +39,26 @@ To create a new on-call schedule: 1. Provide a name and review available schedule settings 1. When you’re done, click **Create Schedule** ->**Note:** You can edit your schedule settings at any time. +>**Note:** You can edit your schedule settings at any time. ### Add a rotation to your on-call schedule + After creating your schedule, you can add rotations to build out your coverage needs. Think of a rotation as a recurring schedule containing on-call shifts that users rotate through. To add a rotation to an on-call schedule: 1. From your newly created schedule, click **+ Add rotation** and select **New Layer**. -1. Complete the rotation creation form according to your rotation parameters. -1. Add users to the rotation from the dropdown. -You can separate users into user groups to rotate through individual users per shift. User groups that contain multiple users results in all users in the group being included in corresponding shifts. -1. When you’re satisfied with the rotation preview, click **Create**. - - -[comment]: <> (For further instruction on rotation configuration, refer to Manage and configure rotations) +2. Complete the rotation creation form according to your rotation parameters. +3. Add users to the rotation from the dropdown. +You can separate users into user groups to rotate through individual users per shift. User groups that contain +4. multiple users results in all users in the group being included in corresponding shifts. +5. When you’re satisfied with the rotation preview, click **Create**. ### Add an on-call schedule to escalation chains -Now that you’ve created your schedule, it must be referenced in the steps of an escalation chain for on-call users to receive alert notifications. +Now that you’ve created your schedule, it must be referenced in the steps of an escalation chain for on-call users +to receive alert notifications. To connect a schedule to an escalation chain: @@ -62,8 +66,9 @@ To connect a schedule to an escalation chain: 1. Navigate to an existing escalation chain or click **+ New Escalation Chain**. 1. Select **Notify users from on-call schedule** from the **Add escalation step** dropdown. 1. Specify which notification policy to use and the appropriate schedule. -1. Click and drag the escalation steps to reorder, if needed. +1. Click and drag the escalation steps to reorder, if needed. -Escalation chain steps are saved automatically. +Escalation chain steps are saved automatically. -For more information about Escalation Chains, refer to [Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}}) \ No newline at end of file +For more information about Escalation Chains, refer to +[Configure and manage Escalation Chains]({{< relref "../../../escalation-policies/configure-escalation-chains" >}}) From 46b39b2c878b4dc9d0040d217fccef69600c0cbf Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Tue, 24 Jan 2023 18:13:21 +0800 Subject: [PATCH 29/33] Remove resolved and acknowledged filters as we switched to status (#1201) # What this PR does ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- CHANGELOG.md | 1 + engine/apps/api/views/alert_group.py | 2 -- grafana-plugin/src/models/alertgroup/alertgroup.ts | 6 ------ 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8beadadb..1354cdd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Slack slash command allowing to trigger a direct page via a manually created alert group +- Remove resolved and acknowledged filters as we switched to status ([#1201](https://github.com/grafana/oncall/pull/1201)) - Add sync with grafana on /users and /teams api calls from terraform plugin ### Changed diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 1dc8f281..d2457a4f 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -91,8 +91,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt model = AlertGroup fields = [ "id__in", - "resolved", - "acknowledged", "started_at_gte", "started_at_lte", "resolved_at_lte", diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index bf332635..8705e3a4 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -314,8 +314,6 @@ export class AlertGroupStore extends BaseStore { const result = await makeRequest(`${this.path}stats/`, { params: { ...this.incidentFilters, - resolved: false, - acknowledged: false, status: [IncidentStatus.New], }, }); @@ -327,8 +325,6 @@ export class AlertGroupStore extends BaseStore { const result = await makeRequest(`${this.path}stats/`, { params: { ...this.incidentFilters, - resolved: false, - acknowledged: true, status: [IncidentStatus.Acknowledged], }, }); @@ -341,7 +337,6 @@ export class AlertGroupStore extends BaseStore { const result = await makeRequest(`${this.path}stats/`, { params: { ...this.incidentFilters, - resolved: true, status: [IncidentStatus.Resolved], }, }); @@ -354,7 +349,6 @@ export class AlertGroupStore extends BaseStore { const result = await makeRequest(`${this.path}stats/`, { params: { ...this.incidentFilters, - silenced: true, status: [IncidentStatus.Silenced], }, }); From 3bc593cdb2a0c869aa3fbcc1d831484860d62add Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 24 Jan 2023 11:21:11 +0100 Subject: [PATCH 30/33] When removing Slack ChatOps integration, warn the user of the implications (#1192) # What this PR does - When removing Slack ChatOps integration, warn the user of the implications of doing so + make them confirm the deletion by having to type `DELETE`: ![Screenshot 2023-01-23 at 15 01 27](https://user-images.githubusercontent.com/9406895/214060105-1af61170-3141-488c-8977-2809edb04faa.png) - remove `grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx` component as it is not referenced anywhere + remove `grafana-plugin/src/img/slack_workspace_choose_attention.png` as this was only referenced in `SlackIntegrationButton.tsx` ## Which issue(s) this PR fixes https://github.com/grafana/oncall-private/issues/1588 ## Checklist - [ ] Tests updated (N/A) - [ ] Documentation added (N/A) - [x] `CHANGELOG.md` updated --- CHANGELOG.md | 1 + .../components/WithConfirm/WithConfirm.tsx | 23 +++-- .../SlackIntegrationButton.tsx | 97 ------------------ .../img/slack_workspace_choose_attention.png | Bin 43568 -> 0 bytes .../tabs/SlackSettings/SlackSettings.tsx | 38 ++++--- 5 files changed, 40 insertions(+), 119 deletions(-) delete mode 100644 grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx delete mode 100644 grafana-plugin/src/img/slack_workspace_choose_attention.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 1354cdd2..bb001bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Allow users with `viewer` role to fetch cloud connection status using the internal API ([#1181](https://github.com/grafana/oncall/pull/1181)) +- When removing the Slack ChatOps integration, make it more explicit to the user what the implications of doing so are ### Fixed diff --git a/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx b/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx index e16e288b..569cee31 100644 --- a/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx +++ b/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx @@ -1,18 +1,21 @@ import React, { ReactElement, useCallback, useState } from 'react'; -import { ConfirmModal } from '@grafana/ui'; +import { ConfirmModal, ConfirmModalProps } from '@grafana/ui'; -interface WithConfirmProps { +type WithConfirmProps = Partial & { children: ReactElement; - title?: string; - body?: React.ReactNode; - confirmText?: string; disabled?: boolean; -} - -const WithConfirm = (props: WithConfirmProps) => { - const { children, title = 'Are you sure to delete?', body, confirmText = 'Delete', disabled } = props; +}; +const WithConfirm: React.FC = ({ + title = 'Are you sure to delete?', + confirmText = 'Delete', + body, + description, + confirmationText, + children, + disabled, +}) => { const [showConfirmation, setShowConfirmation] = useState(false); const onClickCallback = useCallback((event) => { @@ -39,6 +42,8 @@ const WithConfirm = (props: WithConfirmProps) => { dismissText="Cancel" onConfirm={onConfirmCallback} body={body} + description={description} + confirmationText={confirmationText} onDismiss={() => { setShowConfirmation(false); }} diff --git a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx b/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx deleted file mode 100644 index 2eab10f6..00000000 --- a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback, useState } from 'react'; - -import { Button, Modal } from '@grafana/ui'; -import { observer } from 'mobx-react'; - -import WithConfirm from 'components/WithConfirm/WithConfirm'; -import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { useStore } from 'state/useStore'; -import { UserActions } from 'utils/authorization'; - -const SlackIntegrationButton = observer((props: { className: string; disabled?: boolean }) => { - const { className, disabled } = props; - - const [showModal, setShowModal] = useState(false); - - const store = useStore(); - - const onInstallModalCallback = useCallback(() => { - setShowModal(true); - }, []); - - const onInstallModalHideCallback = useCallback(() => { - setShowModal(false); - }, []); - - const onRemoveClickCallback = useCallback(() => { - store.slackStore.removeSlackIntegration().then(() => { - store.teamStore.loadCurrentTeam(); - }); - }, []); - - const onInstallClickCallback = useCallback(() => { - store.slackStore.installSlackIntegration(); - }, []); - - if (store.teamStore.currentTeam?.slack_team_identity) { - return ( - - - - - - ); - } - - return ( - <> - - - - {showModal && } - - ); -}); - -interface SlackModalProps { - onHide: () => void; - onConfirm: () => void; -} - -const SlackModal = (props: SlackModalProps) => { - const { onHide, onConfirm } = props; - - return ( - -
- You can view your Slack Workspace at the top-right corner after you are redirected. It should be a Workspace - with App Bot installed: -
- - -
- ); -}; - -export default SlackIntegrationButton; diff --git a/grafana-plugin/src/img/slack_workspace_choose_attention.png b/grafana-plugin/src/img/slack_workspace_choose_attention.png deleted file mode 100644 index 02d9fc730e9540bc9085f6bed12e4635c75ab964..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43568 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 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 558e7a94..3f532d21 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 @@ -1,6 +1,6 @@ import React, { Component } from 'react'; -import { Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui'; +import { Alert, Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -124,8 +124,30 @@ 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" + > @@ -189,16 +211,6 @@ class SlackSettings extends Component { ); }; - renderActionButtons = () => { - - - - - ; - }; - removeSlackIntegration = () => { const { store } = this.props; store.slackStore.removeSlackIntegration().then(() => { From cf1a1cd7f3583b612be9c7cff74c0aeb8a465e3c Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 24 Jan 2023 13:53:54 +0000 Subject: [PATCH 31/33] Remove DynamicSetting usage for mobile app backend on OSS (#1204) # What this PR does Make so there's no need to populate `mobile_app_settings` DynamicSetting when using the OSS license to turn on the mobile app backend. --- engine/apps/mobile_app/backend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py index 381782f6..93e35f30 100644 --- a/engine/apps/mobile_app/backend.py +++ b/engine/apps/mobile_app/backend.py @@ -53,6 +53,10 @@ class MobileAppBackend(BaseMessagingBackend): @staticmethod def is_enabled_for_organization(organization): + # Setting FEATURE_MOBILE_APP_INTEGRATION_ENABLED to True is enough to enable mobile app on OSS instances + if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME: + return True + mobile_app_settings, _ = DynamicSetting.objects.get_or_create( name="mobile_app_settings", defaults={"json_value": {"org_ids": []}} ) From 1fc3f6d3019801026d84962f5febd97f75504835 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 25 Jan 2023 09:12:08 +0800 Subject: [PATCH 32/33] Refactor plugin sync (#1200) # What this PR does This PR adds a shortcut in the plugin synchronisation process, so the existing users will be able login without waiting for the sync task. Every request still starts the background synchronisation task, to be able to propagate the organisation changes faster than periodic task. It means that we don't necessarily need "force reload" button in the interface. For all the other cases (user does not exist, organisation token "not ok", etc) process remains same - plugin will show "Initialising plugin..." until the background task in successfully completed Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + engine/apps/auth_token/auth.py | 8 ++++++++ engine/apps/grafana_plugin/views/sync.py | 26 ++++++++++++++---------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb001bae..04e76b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Removed duplicate API call, in the UI on plugin initial load, to `GET /api/internal/v1/alert_receive_channels` +- Increased plugin startup speed ([#1200](https://github.com/grafana/oncall/pull/1200)) ## v1.1.18 (2023-01-18) diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 97a48bfa..9835b80c 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -96,6 +96,14 @@ class PluginAuthentication(BaseAuthentication): logger.debug(f"Could not get user from grafana request. Context {context}") raise exceptions.AuthenticationFailed("Non-existent or anonymous user.") + @classmethod + def is_user_from_request_present_in_organization(cls, request: Request, organization: Organization) -> User: + try: + cls._get_user(request, organization) + return True + except exceptions.AuthenticationFailed: + return False + class GrafanaIncidentUser(AnonymousUser): @property diff --git a/engine/apps/grafana_plugin/views/sync.py b/engine/apps/grafana_plugin/views/sync.py index 98d3c23e..2180efc7 100644 --- a/engine/apps/grafana_plugin/views/sync.py +++ b/engine/apps/grafana_plugin/views/sync.py @@ -7,6 +7,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from apps.auth_token.auth import PluginAuthentication from apps.grafana_plugin.permissions import PluginTokenVerified from apps.grafana_plugin.tasks.sync import plugin_sync_organization_async from apps.user_management.models import Organization @@ -22,26 +23,29 @@ class PluginSyncView(GrafanaHeadersMixin, APIView): stack_id = self.instance_context["stack_id"] org_id = self.instance_context["org_id"] is_installed = False - + allow_signup = True try: organization = Organization.objects.get(stack_id=stack_id, org_id=org_id) - if organization.api_token_status == Organization.API_TOKEN_STATUS_OK: is_installed = True - organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING - organization.save(update_fields=["api_token_status"]) + user_is_present_in_org = PluginAuthentication.is_user_from_request_present_in_organization( + request, organization + ) + if not user_is_present_in_org: + organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING + organization.save(update_fields=["api_token_status"]) + + if not organization: + DynamicSetting = apps.get_model("base", "DynamicSetting") + allow_signup = DynamicSetting.objects.get_or_create( + name="allow_plugin_organization_signup", defaults={"boolean_value": True} + )[0].boolean_value + plugin_sync_organization_async.apply_async((organization.pk,)) except Organization.DoesNotExist: logger.info(f"Organization for stack {stack_id} org {org_id} was not found") - allow_signup = True - if not organization: - DynamicSetting = apps.get_model("base", "DynamicSetting") - allow_signup = DynamicSetting.objects.get_or_create( - name="allow_plugin_organization_signup", defaults={"boolean_value": True} - )[0].boolean_value - return Response( status=status.HTTP_202_ACCEPTED, data={ From e5d2d8e72729c47e5b7f0162ff8fac562291dc07 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 25 Jan 2023 12:02:06 +0800 Subject: [PATCH 33/33] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e76b25..84719f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.1.18 (2023-01-25) ### Added