Co-authored-by: Roman Pertl <533172+roock@users.noreply.github.com>
Co-authored-by: Joey Orlando <joseph.t.orlando@gmail.com>
Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2023-05-18 17:21:37 +08:00 committed by GitHub
parent a155be2ca0
commit d67ac8eca3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 3980 additions and 3415 deletions

View file

@ -5,7 +5,7 @@ name: Build and Release
steps:
- name: Build Plugin
image: node:14.17.0-buster
image: node:18.16.0-buster
commands:
- apt-get update
- apt-get --assume-yes install jq
@ -16,7 +16,7 @@ steps:
- ls ./
- name: Sign and Package Plugin
image: node:14.17.0-buster
image: node:18.16.0-buster
environment:
GRAFANA_API_KEY:
from_secret: gcom_plugin_publisher_api_key
@ -174,7 +174,7 @@ name: OSS plugin release
steps:
- name: build plugin
image: node:14.17.0-buster
image: node:18.16.0-buster
commands:
- apt-get update
- apt-get --assume-yes install jq
@ -185,7 +185,7 @@ steps:
- ls ./
- name: sign and package plugin
image: node:14.17.0-buster
image: node:18.16.0-buster
environment:
GRAFANA_API_KEY:
from_secret: gcom_plugin_publisher_api_key
@ -418,4 +418,4 @@ kind: secret
name: drone_token
---
kind: signature
hmac: 8f34bbbb5a2efe479b40d616b087a0c380390de2e440857bdffe8fd48d860e55
hmac: 3ef960dd2f2a121d795f7fe0b5447f7695bf6cd8f6fe552cd4dbab15d0acc9ee

View file

@ -32,7 +32,7 @@ jobs:
# following 2 steps - need to install the frontend dependencies for the eslint/prettier/stylelint steps
- uses: actions/setup-node@v3
with:
node-version: 14.17.0
node-version: 18.16.0
cache: "yarn"
cache-dependency-path: grafana-plugin/yarn.lock
- name: Use cached frontend dependencies
@ -54,7 +54,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 14.17.0
node-version: 18.16.0
cache: "yarn"
cache-dependency-path: grafana-plugin/yarn.lock
- name: Use cached frontend dependencies
@ -285,7 +285,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 14.17.0
node-version: 18.16.0
cache: "yarn"
cache-dependency-path: grafana-plugin/yarn.lock

View file

@ -22,7 +22,7 @@ jobs:
cache-dependency-path: engine/requirements.txt
- uses: actions/setup-node@v3
with:
node-version: 14.17.0
node-version: 18.16.0
cache: "yarn"
cache-dependency-path: grafana-plugin/yarn.lock
- uses: snyk/actions/setup@master

1
.nvmrc
View file

@ -1 +0,0 @@
14.17.0

View file

@ -5,6 +5,12 @@ 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).
## v1.2.25 (2023-05-18)
### Added
- Test mobile push backend
## v1.2.24 (2023-05-17)
### Fixed

View file

@ -68,6 +68,10 @@ define run_engine_docker_command
$(call run_docker_compose_command,run --rm oncall_engine_commands $(1))
endef
define run_ui_docker_command
$(call run_docker_compose_command,run --rm oncall_ui sh -c '$(1)')
endef
# touch SQLITE_DB_FILE if it does not exist and DB is eqaul to SQLITE_PROFILE
start: ## start all of the docker containers
ifeq ($(DB),$(SQLITE_PROFILE))
@ -83,7 +87,7 @@ init: ## build the frontend plugin code then run make start
# this makes sure that it will be available when the grafana container starts up without the need to
# restart the grafana container initially
ifeq ($(findstring $(UI_PROFILE),$(COMPOSE_PROFILES)),$(UI_PROFILE))
cd grafana-plugin && yarn install && yarn build:dev
$(call run_ui_docker_command,yarn install && yarn build:dev)
endif
stop: # stop all of the docker containers
@ -136,6 +140,19 @@ engine-manage: ## run Django's `manage.py` script, inside of a docker container
## https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations
$(call run_engine_docker_command,python manage.py $(CMD))
ui-test: ## run the UI tests
$(call run_ui_docker_command,yarn test)
ui-lint: ## run the UI linter
$(call run_ui_docker_command,yarn lint)
ui-build: ## build the UI
$(call run_ui_docker_command,yarn build)
ui-command: ## run any command, inside of a UI docker container, passing `$CMD` as arguments.
## e.g. `make ui-command CMD="yarn test"`
$(call run_ui_docker_command,$(CMD))
exec-engine: ## exec into engine container's bash
docker exec -it oncall_engine bash

View file

@ -192,8 +192,6 @@ yarn test:integration
## Useful `make` commands
See [`COMPOSE_PROFILES`](#compose_profiles) for more information on what this option is and how to configure it.
> 🚶This part was moved to `make help` command. Run it to see all the available commands and their descriptions
## Setting environment variables

View file

@ -52,7 +52,7 @@ services:
MOBILE_APP_QR_INTERVAL_QUEUE: 290000 # 4 minutes and 50 seconds
volumes:
- ./grafana-plugin:/etc/app
- /etc/app/node_modules
- node_modules_dev:/etc/app/node_modules
# https://stackoverflow.com/a/60456034
- ${ENTERPRISE_FRONTEND:-/dev/null}:/etc/app/frontend_enterprise
profiles:
@ -313,6 +313,8 @@ volumes:
labels: *oncall-labels
mysqldata_dev:
labels: *oncall-labels
node_modules_dev:
labels: *oncall-labels
networks:
default:

View file

@ -4,3 +4,8 @@ from rest_framework.throttling import UserRateThrottle
class TestCallThrottler(UserRateThrottle):
scope = "make_test_call"
rate = "5/m"
class TestPushThrottler(UserRateThrottle):
scope = "send_test_push"
rate = "10/m"

View file

@ -14,6 +14,7 @@ FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
FEATURE_WEB_SCHEDULES = "web_schedules"
FEATURE_WEBHOOKS2 = "webhooks2"
FEATURE_MOBILE_TEST_PUSH = "mobile_test_push"
class FeaturesAPIView(APIView):

View file

@ -33,12 +33,15 @@ from apps.api.throttlers import (
VerifyPhoneNumberThrottlerPerOrg,
VerifyPhoneNumberThrottlerPerUser,
)
from apps.api.throttlers.test_call_throttler import TestPushThrottler
from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import UserScheduleExportAuthToken
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.mobile_app.demo_push import send_test_push
from apps.mobile_app.exceptions import DeviceNotSet
from apps.schedules.models import OnCallSchedule
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramVerificationCode
@ -156,6 +159,7 @@ class UserView(
"unlink_telegram": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"send_test_push": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
}
@ -177,6 +181,7 @@ class UserView(
"unlink_telegram",
"unlink_backend",
"make_test_call",
"send_test_push",
"export_token",
"upcoming_shifts",
],
@ -391,6 +396,25 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], throttle_classes=[TestPushThrottler])
def send_test_push(self, request, pk):
user = self.get_object()
critical = request.query_params.get("critical", "false") == "true"
try:
send_test_push(user, critical)
except DeviceNotSet:
return Response(
data="Mobile device not connected",
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.info(f"UserView.send_test_push: Unable to send test push due to {e}")
return Response(
data="Something went wrong while sending a test push", status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["get"])
def get_backend_verification_code(self, request, pk):
backend_id = request.query_params.get("backend")

View file

@ -0,0 +1,86 @@
import json
import random
import string
from fcm_django.models import FCMDevice
from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound, Message
from apps.mobile_app.exceptions import DeviceNotSet
from apps.mobile_app.tasks import FCMMessageData, MessageType, _construct_fcm_message, _send_push_notification, logger
from apps.user_management.models import User
TEST_PUSH_TITLE = "Hi, this is a test notification from Grafana OnCall"
def send_test_push(user, critical=False):
device_to_notify = FCMDevice.objects.filter(user=user).first()
if device_to_notify is None:
logger.info(f"send_test_push: fcm_device not found user_id={user.id}")
raise DeviceNotSet
message = _get_test_escalation_fcm_message(user, device_to_notify, critical)
_send_push_notification(device_to_notify, message)
def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, critical: bool) -> Message:
# TODO: this method is copied from _get_alert_group_escalation_fcm_message
# to have same notification/sound/overrideDND logic. Ideally this logic should be abstracted, not repeated.
from apps.mobile_app.models import MobileAppUserSettings
thread_id = f"{''.join(random.choices(string.digits, k=6))}:test_push"
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
# critical defines the type of notification.
# we use overrideDND to establish if the notification should sound even if DND is on
overrideDND = critical and mobile_app_user_settings.important_notification_override_dnd
# APNS only allows to specify volume for critical notifications
apns_volume = mobile_app_user_settings.important_notification_volume if critical else None
apns_sound_name = (
mobile_app_user_settings.important_notification_sound_name
if critical
else mobile_app_user_settings.default_notification_sound_name
) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension
fcm_message_data: FCMMessageData = {
"title": TEST_PUSH_TITLE,
# Pass user settings, so the Android app can use them to play the correct sound and volume
"default_notification_sound_name": (
mobile_app_user_settings.default_notification_sound_name
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"default_notification_volume_type": mobile_app_user_settings.default_notification_volume_type,
"default_notification_volume": str(mobile_app_user_settings.default_notification_volume),
"default_notification_volume_override": json.dumps(
mobile_app_user_settings.default_notification_volume_override
),
"important_notification_sound_name": (
mobile_app_user_settings.important_notification_sound_name
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"important_notification_volume_type": mobile_app_user_settings.important_notification_volume_type,
"important_notification_volume": str(mobile_app_user_settings.important_notification_volume),
"important_notification_volume_override": json.dumps(
mobile_app_user_settings.important_notification_volume_override
),
"important_notification_override_dnd": json.dumps(mobile_app_user_settings.important_notification_override_dnd),
}
apns_payload = APNSPayload(
aps=Aps(
thread_id=thread_id,
alert=ApsAlert(title=TEST_PUSH_TITLE),
sound=CriticalSound(
# The notification shouldn't be critical if the user has disabled "override DND" setting
critical=overrideDND,
name=apns_sound_name,
volume=apns_volume,
),
custom_data={
"interruption-level": "critical" if overrideDND else "time-sensitive",
},
),
)
message_type = MessageType.CRITICAL if critical else MessageType.NORMAL
return _construct_fcm_message(message_type, device_to_notify, thread_id, fcm_message_data, apns_payload)

View file

@ -0,0 +1,6 @@
class DeviceNotSet(Exception):
"""
Indicates that user has no connected fcm device.
Introduced only for test_push_notification handler.
We should have generic test notifications system across all messaging backends.
"""

View file

@ -0,0 +1,95 @@
import pytest
from fcm_django.models import FCMDevice
from apps.mobile_app.demo_push import TEST_PUSH_TITLE, _get_test_escalation_fcm_message
from apps.mobile_app.models import MobileAppUserSettings
@pytest.mark.django_db
def test_test_escalation_fcm_message_user_settings(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
message = _get_test_escalation_fcm_message(user, device, critical=False)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.8"
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
assert message.data["important_notification_volume_type"] == "constant"
assert message.data["important_notification_volume"] == "0.8"
assert message.data["important_notification_volume_override"] == "true"
assert message.data["important_notification_override_dnd"] == "true"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is False
assert apns_sound.name == "default_sound.aiff"
assert apns_sound.volume is None # APNS doesn't allow to specify volume for non-critical notifications
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
assert message.data["title"] == TEST_PUSH_TITLE
@pytest.mark.django_db
def test_escalation_fcm_message_user_settings_critical(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
message = _get_test_escalation_fcm_message(user, device, critical=True)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.8"
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
assert message.data["important_notification_volume_type"] == "constant"
assert message.data["important_notification_volume"] == "0.8"
assert message.data["important_notification_volume_override"] == "true"
assert message.data["important_notification_override_dnd"] == "true"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is True
assert apns_sound.name == "default_sound_important.aiff"
assert apns_sound.volume == 0.8
assert message.apns.payload.aps.custom_data["interruption-level"] == "critical"
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
assert message.data["title"] == TEST_PUSH_TITLE
@pytest.mark.django_db
def test_escalation_fcm_message_user_settings_critical_override_dnd_disabled(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
# Disable important notification override DND
MobileAppUserSettings.objects.create(user=user, important_notification_override_dnd=False)
message = _get_test_escalation_fcm_message(user, device, critical=True)
# Check user settings are passed to FCM message
assert message.data["important_notification_override_dnd"] == "false"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is False
assert message.apns.payload.aps.custom_data["interruption-level"] == "time-sensitive"
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
assert message.data["title"] == TEST_PUSH_TITLE

View file

@ -1,14 +1,6 @@
FROM node:14.17.0-alpine
FROM node:18.16.0-alpine
WORKDIR /etc/app
ENV PATH /etc/app/node_modules/.bin:$PATH
# this allows hot reloading of the container
# https://stackoverflow.com/a/72478714
ENV WATCHPACK_POLLING true
COPY ./package.json ./
COPY ./yarn.lock ./
RUN yarn install
CMD ["yarn", "start"]

View file

@ -56,8 +56,8 @@
"@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@grafana/eslint-config": "^5.0.0",
"@grafana/toolkit": "^9.2.4",
"@grafana/eslint-config": "^5.1.0",
"@grafana/toolkit": "^9.5.2",
"@jest/globals": "^27.5.1",
"@playwright/test": "^1.32.0",
"@testing-library/jest-dom": "^5.16.5",
@ -81,7 +81,7 @@
"dompurify": "^2.3.12",
"dotenv": "^16.0.3",
"eslint": "^8.25.0",
"eslint-plugin-jsdoc": "^39.3.14",
"eslint-plugin-jsdoc": "^44.2.4",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-rulesdir": "^0.2.1",

View file

@ -14,6 +14,11 @@
}
}
.notification-buttons {
width: 100%;
padding-top: 12px;
}
.icon {
margin-top: -6px;
margin-left: 4px;

View file

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
@ -12,6 +12,7 @@ import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/W
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification, openWarningNotification } from 'utils';
import { UserActions } from 'utils/authorization';
import styles from './MobileAppConnection.module.scss';
@ -73,6 +74,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
const [userTimeoutId, setUserTimeoutId] = useState<NodeJS.Timeout>(undefined);
const [refreshTimeoutId, setRefreshTimeoutId] = useState<NodeJS.Timeout>(undefined);
const [isQRBlurry, setIsQRBlurry] = useState<boolean>(false);
const [isAttemptingTestNotification, setIsAttemptingTestNotification] = useState(false);
const isCurrentUser = userStore.currentUserPk === userPk;
const fetchQRCode = useCallback(
async (showLoader = true) => {
@ -188,17 +191,58 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
userAction={UserActions.UserSettingsWrite}
message="You do not have permission to perform this action. Ask an admin to upgrade your permissions."
>
<div className={cx('container')}>
<Block shadowed bordered withBackground className={cx('container__box')}>
<DownloadIcons />
</Block>
<Block shadowed bordered withBackground className={cx('container__box')}>
{content}
</Block>
</div>
<VerticalGroup>
<div className={cx('container')}>
<Block shadowed bordered withBackground className={cx('container__box')}>
<DownloadIcons />
</Block>
<Block shadowed bordered withBackground className={cx('container__box')}>
{content}
</Block>
</div>
{false && // temporary disable test notifications
mobileAppIsCurrentlyConnected &&
isCurrentUser && (
<div className={cx('notification-buttons')}>
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
<Button
variant="secondary"
onClick={() => onSendTestNotification()}
disabled={isAttemptingTestNotification}
>
Send Test Push notification
</Button>
<Button
variant="destructive"
onClick={() => onSendTestNotification(true)}
disabled={isAttemptingTestNotification}
>
Send Critical Test Push notification
</Button>
</HorizontalGroup>
</div>
)}
</VerticalGroup>
</WithPermissionControlDisplay>
);
async function onSendTestNotification(isCritical = false) {
setIsAttemptingTestNotification(true);
try {
await userStore.sendTestPushNotification(userPk, isCritical);
openNotification('Notification was sent');
} catch (ex) {
if (ex.response?.status === 429) {
openWarningNotification('Too much attempts, try again later');
} else {
openErrorNotification('There was an error sending the notification');
}
} finally {
setIsAttemptingTestNotification(false);
}
}
function getParsedQRCodeValue() {
try {
return JSON.parse(QRCodeValue);

View file

@ -45,7 +45,7 @@ const originalError = console.error;
beforeEach(() => {
delete global.window.location;
global.window = Object.create(window);
global.window ??= Object.create(window);
global.window.location = {
protocol: MOCK_PROTOCOL,
host: MOCK_HOST,

View file

@ -347,6 +347,16 @@ export class UserStore extends BaseStore {
this.notificationChoices = get(response, 'actions.POST', []);
}
@action
async sendTestPushNotification(userId: User['pk'], isCritical: boolean) {
return await makeRequest(`/users/${userId}/send_test_push`, {
method: 'POST',
params: {
critical: isCritical,
},
});
}
@action
async updateNotifyByOptions() {
const response = await makeRequest('/notification_policies/notify_by_options/', {});

File diff suppressed because it is too large Load diff