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:
Vadim Stepanov 2023-01-19 11:15:56 +00:00 committed by GitHub
parent 8e0438ddc8
commit 6b87ad74e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 527 additions and 33 deletions

View file

@ -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"]

View file

@ -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

View file

View 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

View 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

View file

@ -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"),
]

View file

@ -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):

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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());

View file

@ -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

View file

@ -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}

View file

@ -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>
)}

View file

@ -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' });
}

View file

@ -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>
);

View file

@ -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) {