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): <img width="1088" alt="Screenshot 2023-01-12 at 18 48 58" src="https://user-images.githubusercontent.com/20116910/212156591-86906020-eddf-43f1-9402-7ebb7547c7e6.png"> When a user tries to use mobile push notifications as a personal notification step and cloud is not connected: <img width="764" alt="Screenshot 2023-01-12 at 19 01 10" src="https://user-images.githubusercontent.com/20116910/212157580-9abb0758-79ad-4316-b8cd-15b4fff01502.png"> Now on the "Cloud" tab there's some info about the mobile app (the last section at the bottom of the page): <img width="1245" alt="Screenshot 2023-01-12 at 18 49 10" src="https://user-images.githubusercontent.com/20116910/212156997-c8b70dd5-bf15-4bc7-8eb8-9decdb8ecc80.png"> After connecting to the cloud instance, everything goes back to active and it's now possible to connect the mobile app: <img width="1091" alt="Screenshot 2023-01-12 at 19 08 27" src="https://user-images.githubusercontent.com/20116910/212158811-60d49888-4714-4c0e-850f-3ff6a11a117a.png"> After connecting the app the warning is gone: <img width="764" alt="Screenshot 2023-01-12 at 19 07 00" src="https://user-images.githubusercontent.com/20116910/212158614-677ab889-127f-4d64-bacc-0c26887f3097.png">
This commit is contained in:
parent
8e0438ddc8
commit
6b87ad74e9
16 changed files with 527 additions and 33 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
engine/apps/mobile_app/tests/__init__.py
Normal file
0
engine/apps/mobile_app/tests/__init__.py
Normal file
81
engine/apps/mobile_app/tests/test_fcm_relay.py
Normal file
81
engine/apps/mobile_app/tests/test_fcm_relay.py
Normal file
|
|
@ -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
|
||||
171
engine/apps/mobile_app/tests/test_notify_user.py
Normal file
171
engine/apps/mobile_app/tests/test_notify_user.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.Component<NotificationPolicyProps,
|
|||
}
|
||||
}
|
||||
|
||||
_renderMobileAppNote() {
|
||||
const { isMobileAppConnected, showCloudConnectionWarning } = this.props;
|
||||
|
||||
if (showCloudConnectionWarning) {
|
||||
return <PolicyNote type="danger">Cloud is not connected</PolicyNote>;
|
||||
}
|
||||
|
||||
if (!isMobileAppConnected) {
|
||||
return <PolicyNote type="danger">Mobile app is not connected</PolicyNote>;
|
||||
}
|
||||
|
||||
return <PolicyNote type="success">Mobile app is connected</PolicyNote>;
|
||||
}
|
||||
|
||||
_renderTelegramNote() {
|
||||
const { telegramVerified } = this.props;
|
||||
|
||||
|
|
@ -203,6 +219,12 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
|
|||
case 3:
|
||||
return <>{this._renderTelegramNote()}</>;
|
||||
|
||||
case 5:
|
||||
return <>{this._renderMobileAppNote()}</>;
|
||||
|
||||
case 6:
|
||||
return <>{this._renderMobileAppNote()}</>;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof useStoreOriginal>>;
|
||||
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 <Link> outside a <Router>"
|
||||
const component = render(
|
||||
<MemoryRouter>
|
||||
<MobileAppConnection userPk={USER_PK} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="secondary">Please connect Cloud OnCall to use the mobile app</Text>
|
||||
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
|
||||
<PluginLink query={{ page: 'cloud' }}>
|
||||
<Button variant="secondary" icon="external-link-alt">
|
||||
Connect Cloud OnCall
|
||||
</Button>
|
||||
</PluginLink>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your
|
||||
permissions.
|
||||
</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState<boolean>(isUserConnected());
|
||||
|
|
|
|||
|
|
@ -2774,6 +2774,59 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-1j7sh2x-vertical-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Please connect Cloud OnCall to use the mobile app
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-ztyofd-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="root"
|
||||
href="/a/grafana-oncall-app/cloud"
|
||||
>
|
||||
<button
|
||||
class="css-1a8393j-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect Cloud OnCall
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin
|
|||
return Number(user.verified_phone_number) + 2;
|
||||
};
|
||||
|
||||
// Mobile app related NotificationPolicy props
|
||||
const isMobileAppConnected = user.messaging_backends['MOBILE_APP']?.connected;
|
||||
const showCloudConnectionWarning =
|
||||
store.hasFeature(AppFeature.CloudConnection) && !store.cloudStore.cloudConnectionStatus.cloud_connection_status;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
{isUserActionAllowed(UserActions.OtherSettingsWrite) ? (
|
||||
<VerticalGroup spacing="lg">
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
{syncing ? (
|
||||
<Button variant="secondary" icon="sync" disabled>
|
||||
Updating...
|
||||
|
|
@ -137,7 +137,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
</VerticalGroup>
|
||||
) : (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text>You do not have permission to perform this action. Ask an admin to upgrade your permissions.</Text>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export class CloudStore extends BaseStore {
|
|||
@observable.shallow
|
||||
items: { [id: string]: Cloud } = {};
|
||||
|
||||
@observable
|
||||
cloudConnectionStatus: { cloud_connection_status: boolean } = { cloud_connection_status: false };
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
@ -67,6 +70,10 @@ export class CloudStore extends BaseStore {
|
|||
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async loadCloudConnectionStatus() {
|
||||
this.cloudConnectionStatus = await this.getCloudConnectionStatus();
|
||||
}
|
||||
|
||||
async getCloudConnectionStatus() {
|
||||
return await makeRequest(`/cloud_connection/`, { method: 'GET' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Text from 'components/Text/Text';
|
|||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import { CrossCircleIcon, HeartIcon } from 'icons';
|
||||
import { Cloud } from 'models/cloud/cloud.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -60,9 +61,10 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
setApiKeyError(false);
|
||||
}, []);
|
||||
|
||||
const disconnectCloudOncall = () => {
|
||||
const disconnectCloudOncall = async () => {
|
||||
setCloudIsConnected(false);
|
||||
store.cloudStore.disconnectToCloud();
|
||||
await store.cloudStore.disconnectToCloud();
|
||||
await store.cloudStore.loadCloudConnectionStatus();
|
||||
};
|
||||
|
||||
const connectToCloud = async () => {
|
||||
|
|
@ -81,6 +83,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
const heartbeatData: { link: string } = await store.cloudStore.getCloudHeartbeat();
|
||||
setheartbeatLink(heartbeatData?.link);
|
||||
}
|
||||
await store.cloudStore.loadCloudConnectionStatus();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -312,6 +315,19 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
</VerticalGroup>
|
||||
)}
|
||||
</Block>
|
||||
{store.hasFeature(AppFeature.MobileApp) && (
|
||||
<Block bordered withBackground className={cx('info-block')}>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={4}>
|
||||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
|
||||
mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
|
|
@ -358,6 +374,19 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<Text type="secondary">Users matched between OSS and Cloud OnCall currently unavailable.</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
{store.hasFeature(AppFeature.MobileApp) && (
|
||||
<Block bordered withBackground className={cx('info-block')}>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={4}>
|
||||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
|
||||
mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export class RootBaseStore {
|
|||
// stores
|
||||
|
||||
async updateBasicData() {
|
||||
return Promise.all([
|
||||
const storeUpdatePromises = [
|
||||
this.teamStore.loadCurrentTeam(),
|
||||
this.grafanaTeamStore.updateItems(),
|
||||
this.updateFeatures(),
|
||||
|
|
@ -109,7 +109,14 @@ export class RootBaseStore {
|
|||
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateEscalationPolicyOptions(),
|
||||
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
|
||||
]);
|
||||
];
|
||||
|
||||
// Only fetch cloud connection status when cloud connection feature is enabled on OSS instance
|
||||
if (this.hasFeature(AppFeature.CloudConnection)) {
|
||||
storeUpdatePromises.push(this.cloudStore.loadCloudConnectionStatus());
|
||||
}
|
||||
|
||||
return Promise.all(storeUpdatePromises);
|
||||
}
|
||||
|
||||
setupPluginError(errorMsg: string) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue