commit
d0f415cfd2
43 changed files with 887 additions and 141 deletions
11
.drone.yml
11
.drone.yml
|
|
@ -236,10 +236,15 @@ steps:
|
|||
|
||||
- name: build and push docker image
|
||||
image: thegeeklab/drone-docker-buildx:24.1.0
|
||||
# From the docs (https://drone-plugin-index.geekdocs.de/plugins/drone-docker-buildx/) regarding privileged=true:
|
||||
#
|
||||
# Be aware that the this plugin requires privileged capabilities, otherwise the integrated
|
||||
# Docker daemon is not able to start.
|
||||
privileged: true
|
||||
settings:
|
||||
repo: grafana/oncall
|
||||
tags: ${DRONE_TAG}
|
||||
platform: linux/arm64/v8,linux/amd64
|
||||
tags: latest,${DRONE_TAG}
|
||||
platforms: linux/arm64/v8,linux/amd64
|
||||
dockerfile: engine/Dockerfile
|
||||
target: prod
|
||||
context: engine/
|
||||
|
|
@ -329,6 +334,6 @@ kind: secret
|
|||
name: drone_token
|
||||
---
|
||||
kind: signature
|
||||
hmac: 08184a4c1313a5e6cbca2370d20a6a8c5d71726b4148b5c3ed1396a21bebaa37
|
||||
hmac: be10373849d65e1f90bce64c7468d5cf5bac546285226ff8931a8b953163a752
|
||||
|
||||
...
|
||||
|
|
|
|||
8
.github/workflows/linting-and-tests.yml
vendored
8
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -468,8 +468,12 @@ jobs:
|
|||
# hit 172.17.0.1 which proxies the request onto the host where port 30001 is the node port that is mapped
|
||||
# to the OnCall API
|
||||
ONCALL_API_URL: http://172.17.0.1:30001
|
||||
GRAFANA_USERNAME: oncall
|
||||
GRAFANA_PASSWORD: oncall
|
||||
GRAFANA_ADMIN_USERNAME: oncall
|
||||
GRAFANA_ADMIN_PASSWORD: oncall
|
||||
GRAFANA_EDITOR_USERNAME: editor
|
||||
GRAFANA_EDITOR_PASSWORD: editor
|
||||
GRAFANA_VIEWER_USERNAME: viewer
|
||||
GRAFANA_VIEWER_PASSWORD: viewer
|
||||
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
|
||||
working-directory: ./grafana-plugin
|
||||
run: yarn test:integration
|
||||
|
|
|
|||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -5,6 +5,13 @@ 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.3.4 (2023-07-05)
|
||||
|
||||
### Added
|
||||
|
||||
- Add full avatar URL for on-call users in schedule internal API by @vadimkerr ([#2414](https://github.com/grafana/oncall/pull/2414))
|
||||
- Add phone call using the zvonok.com service by @sreway ([#2339](https://github.com/grafana/oncall/pull/2339))
|
||||
|
||||
## v1.3.3 (2023-06-29)
|
||||
|
||||
### Added
|
||||
|
|
@ -29,6 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Change permissions used during setup to better represent actions being taken by @mderynck ([#2242](https://github.com/grafana/oncall/pull/2242))
|
||||
- Display 100000+ in stats when there are more than 100000 alert groups in the result ([#1901](https://github.com/grafana/oncall/pull/1901))
|
||||
- Change OnCall plugin to use service accounts and api tokens for communicating with backend, by @mderynck ([#2385](https://github.com/grafana/oncall/pull/2385))
|
||||
- RabbitMQ Docker image upgraded from 3.7.19 to 3.12.0 in `docker-compose-developer.yml` and
|
||||
`docker-compose-mysql-rabbitmq.yml`. **Note**: if you use one of these config files for your deployment
|
||||
you _may_ need to follow the RabbitMQ "upgrade steps" listed [here](https://rabbitmq.com/upgrade.html#rabbitmq-version-upgradability)
|
||||
by @joeyorlando ([#2359](https://github.com/grafana/oncall/pull/2359))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ To run these tests locally simply do the following:
|
|||
|
||||
```bash
|
||||
npx playwright install # install playwright dependencies
|
||||
cp ./grafana-plugin/.env.example ./grafana-plugin/.env
|
||||
cp ./grafana-plugin/integration-tests/.env.example ./grafana-plugin/integration-tests/.env
|
||||
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup
|
||||
cd grafana-plugin
|
||||
yarn test:integration
|
||||
|
|
|
|||
|
|
@ -207,7 +207,9 @@ The benefits of connecting to Grafana Cloud include:
|
|||
|
||||
To connect to Grafana Cloud, refer to the **Cloud** page in your OSS Grafana OnCall instance.
|
||||
|
||||
## Twilio Setup
|
||||
## Supported Phone Providers
|
||||
|
||||
### Twilio
|
||||
|
||||
Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call
|
||||
notifications using Twilio, complete the following steps:
|
||||
|
|
@ -215,6 +217,30 @@ notifications using Twilio, complete the following steps:
|
|||
1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled.
|
||||
1. From your **OnCall** environment, select **Env Variables** and configure all variables starting with `TWILIO_`.
|
||||
|
||||
### Zvonok.com
|
||||
|
||||
Grafana OnCall supports Zvonok.com phone call notifications delivery. To configure phone call notifications using
|
||||
Zvonok.com, complete the following steps:
|
||||
|
||||
1. Change `PHONE_PROVIDER` value to `zvonok`.
|
||||
2. Create a public API key on the Profile->Settings page, and assign its value to `ZVONOK_API_KEY`.
|
||||
3. Create campaign and assign its ID value to `ZVONOK_CAMPAIGN_ID`.
|
||||
4. If you are planning to use pre-recorded audio instead of a speech synthesizer, you can copy the ID of the audio clip
|
||||
to the variable `ZVONOK_AUDIO_ID` (optional step).
|
||||
5. To make a call with a specific voice, you can set the `ZVONOK_SPEAKER_ID`.
|
||||
By default, the ID used is `Salli` (optional step).
|
||||
6. To process the call status, it is required to add a postback with the GET/POST method on the side of the zvonok.com
|
||||
service with the following format (optional step):
|
||||
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
|
||||
|
||||
The names of the transmitted parameters can be redefined through environment variables:
|
||||
|
||||
- `ZVONOK_POSTBACK_CALL_ID` - call id (ct_call_id) query parameter name
|
||||
- `ZVONOK_POSTBACK_CAMPAIGN_ID` - company id (ct_campaign_id) query parameter name
|
||||
- `ZVONOK_POSTBACK_STATUS` - status (ct_status) query parameter name
|
||||
- `ZVONOK_POSTBACK_USER_CHOICE` - user choice (ct_user_choice) query parameter name
|
||||
- `ZVONOK_POSTBACK_USER_CHOICE_ACK` - user choice (ct_user_choice) query parameter value for acknowledge alert group
|
||||
|
||||
## Email Setup
|
||||
|
||||
Grafana OnCall is capable of sending emails using SMTP as a user notification step. To setup email notifications, populate
|
||||
|
|
|
|||
|
|
@ -1858,3 +1858,36 @@ def test_get_schedule_from_other_team_with_flag(
|
|||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_on_call_now(
|
||||
make_organization, make_user_for_organization, make_token_for_organization, make_schedule, make_user_auth_headers
|
||||
):
|
||||
organization = make_organization(grafana_url="https://example.com")
|
||||
user = make_user_for_organization(organization, username="test", avatar_url="/avatar/test123")
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-list")
|
||||
with patch(
|
||||
"apps.schedules.models.on_call_schedule.OnCallScheduleQuerySet.get_oncall_users",
|
||||
return_value={schedule.pk: [user]},
|
||||
):
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["results"][0]["on_call_now"] == [
|
||||
{
|
||||
"pk": user.public_primary_key,
|
||||
"username": "test",
|
||||
"avatar": "/avatar/test123",
|
||||
"avatar_full": "https://example.com/avatar/test123",
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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):
|
||||
|
|
@ -64,12 +63,4 @@ class FeaturesAPIView(APIView):
|
|||
if is_webhooks_enabled_for_organization(request.auth.organization.pk):
|
||||
enabled_features.append(FEATURE_WEBHOOKS2)
|
||||
|
||||
enabled_mobile_test_push = DynamicSetting.objects.get_or_create(
|
||||
name="enabled_mobile_test_push",
|
||||
defaults={"boolean_value": False},
|
||||
)[0]
|
||||
|
||||
if enabled_mobile_test_push.boolean_value:
|
||||
enabled_features.append(FEATURE_MOBILE_TEST_PUSH)
|
||||
|
||||
return enabled_features
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ class LiveSetting(models.Model):
|
|||
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
|
||||
"DANGEROUS_WEBHOOKS_ENABLED",
|
||||
"PHONE_PROVIDER",
|
||||
"ZVONOK_API_KEY",
|
||||
"ZVONOK_CAMPAIGN_ID",
|
||||
"ZVONOK_AUDIO_ID",
|
||||
"ZVONOK_SPEAKER_ID",
|
||||
"ZVONOK_POSTBACK_CALL_ID",
|
||||
"ZVONOK_POSTBACK_CAMPAIGN_ID",
|
||||
"ZVONOK_POSTBACK_STATUS",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE_ACK",
|
||||
)
|
||||
|
||||
DESCRIPTIONS = {
|
||||
|
|
@ -148,6 +157,15 @@ class LiveSetting(models.Model):
|
|||
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
|
||||
"DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks",
|
||||
"PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}",
|
||||
"ZVONOK_API_KEY": "API public key. You can get it in Profile->Settings section.",
|
||||
"ZVONOK_CAMPAIGN_ID": "Calls by API campaign ID. You can get it after campaign creation.",
|
||||
"ZVONOK_AUDIO_ID": "Calls with specific audio. You can get it in Audioclips section.",
|
||||
"ZVONOK_SPEAKER_ID": "Calls with speaker.",
|
||||
"ZVONOK_POSTBACK_CALL_ID": "'Postback' call id (ct_call_id) query parameter name to validate a postback request.",
|
||||
"ZVONOK_POSTBACK_CAMPAIGN_ID": "'Postback' company id (ct_campaign_id) query parameter name to validate a postback request.",
|
||||
"ZVONOK_POSTBACK_STATUS": "'Postback' status (ct_status) query parameter name to validate a postback request.",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE": "'Postback' user choice (ct_user_choice) query parameter name (optional).",
|
||||
"ZVONOK_POSTBACK_USER_CHOICE_ACK": "'Postback' user choice (ct_user_choice) query parameter value for acknowledge alert group (optional).",
|
||||
}
|
||||
|
||||
SECRET_SETTING_NAMES = (
|
||||
|
|
@ -163,6 +181,7 @@ class LiveSetting(models.Model):
|
|||
"SLACK_SIGNING_SECRET",
|
||||
"TELEGRAM_TOKEN",
|
||||
"GRAFANA_CLOUD_ONCALL_TOKEN",
|
||||
"ZVONOK_API_KEY",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from rest_framework import exceptions
|
|||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
|
||||
from apps.auth_token.exceptions import InvalidToken
|
||||
from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException
|
||||
from apps.user_management.models import User
|
||||
|
||||
from .models import MobileAppAuthToken, MobileAppVerificationToken
|
||||
|
|
@ -42,4 +43,9 @@ class MobileAppAuthTokenAuthentication(BaseAuthentication):
|
|||
except InvalidToken:
|
||||
return None, None
|
||||
|
||||
if auth_token.organization.is_moved:
|
||||
raise OrganizationMovedException(auth_token.organization)
|
||||
if auth_token.organization.deleted_at:
|
||||
raise OrganizationDeletedException(auth_token.organization)
|
||||
|
||||
return auth_token.user, auth_token
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ 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()
|
||||
|
|
@ -42,7 +40,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
|
|||
) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension
|
||||
|
||||
fcm_message_data: FCMMessageData = {
|
||||
"title": TEST_PUSH_TITLE,
|
||||
"title": get_test_push_title(critical),
|
||||
# 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
|
||||
|
|
@ -68,7 +66,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
|
|||
apns_payload = APNSPayload(
|
||||
aps=Aps(
|
||||
thread_id=thread_id,
|
||||
alert=ApsAlert(title=TEST_PUSH_TITLE),
|
||||
alert=ApsAlert(title=get_test_push_title(critical)),
|
||||
sound=CriticalSound(
|
||||
# The notification shouldn't be critical if the user has disabled "override DND" setting
|
||||
critical=overrideDND,
|
||||
|
|
@ -84,3 +82,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
|
|||
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)
|
||||
|
||||
|
||||
def get_test_push_title(critical: bool) -> str:
|
||||
return f"Hi, this is a {'critical ' if critical else ''}test notification from Grafana OnCall"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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.demo_push import _get_test_escalation_fcm_message, get_test_push_title
|
||||
from apps.mobile_app.models import MobileAppUserSettings
|
||||
|
||||
|
||||
|
|
@ -33,8 +33,8 @@ def test_test_escalation_fcm_message_user_settings(
|
|||
|
||||
# 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
|
||||
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=False)
|
||||
assert message.data["title"] == get_test_push_title(critical=False)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -66,8 +66,8 @@ def test_escalation_fcm_message_user_settings_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
|
||||
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
|
||||
assert message.data["title"] == get_test_push_title(critical=True)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -91,5 +91,5 @@ def test_escalation_fcm_message_user_settings_critical_override_dnd_disabled(
|
|||
|
||||
# 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
|
||||
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
|
||||
assert message.data["title"] == get_test_push_title(critical=True)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ class OrganizationMovedMiddleware(MiddlewareMixin):
|
|||
return requests.delete(url, headers=headers)
|
||||
elif method == "OPTIONS":
|
||||
return requests.options(url, headers=headers)
|
||||
elif method == "PATCH":
|
||||
return requests.patch(url, data=body, headers=headers)
|
||||
|
||||
|
||||
class OrganizationDeletedMiddleware(MiddlewareMixin):
|
||||
|
|
|
|||
|
|
@ -270,7 +270,12 @@ class User(models.Model):
|
|||
self._timezone = value
|
||||
|
||||
def short(self):
|
||||
return {"username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url}
|
||||
return {
|
||||
"username": self.username,
|
||||
"pk": self.public_primary_key,
|
||||
"avatar": self.avatar_url,
|
||||
"avatar_full": self.avatar_full_url,
|
||||
}
|
||||
|
||||
# Insight logs
|
||||
@property
|
||||
|
|
|
|||
0
engine/apps/zvonok/__init__.py
Normal file
0
engine/apps/zvonok/__init__.py
Normal file
30
engine/apps/zvonok/migrations/0001_initial.py
Normal file
30
engine/apps/zvonok/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 3.2.19 on 2023-07-01 12:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('phone_notifications', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ZvonokPhoneCall',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'attempts_exc'), (20, 'compl_finished'), (30, 'compl_nofinished'), (40, 'deleted'), (50, 'duration_error'), (60, 'expires'), (70, 'novalid_button'), (80, 'no_provider'), (90, 'interrupted'), (100, 'in_process'), (110, 'pincode_nook'), (130, 'synth_error'), (140, 'user')], null=True)),
|
||||
('call_id', models.CharField(blank=True, max_length=50)),
|
||||
('campaign_id', models.CharField(max_length=50)),
|
||||
('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='zvonok_zvonokphonecall_related', related_query_name='zvonok_zvonokphonecalls', to='phone_notifications.phonecallrecord')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
engine/apps/zvonok/migrations/__init__.py
Normal file
0
engine/apps/zvonok/migrations/__init__.py
Normal file
1
engine/apps/zvonok/models/__init__.py
Normal file
1
engine/apps/zvonok/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .phone_call import ZvonokCallStatuses, ZvonokPhoneCall # noqa: F401
|
||||
71
engine/apps/zvonok/models/phone_call.py
Normal file
71
engine/apps/zvonok/models/phone_call.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from django.db import models
|
||||
|
||||
from apps.phone_notifications.phone_provider import ProviderPhoneCall
|
||||
|
||||
|
||||
class ZvonokCallStatuses:
|
||||
|
||||
ATTEMPTS_EXC = 10
|
||||
COMPL_FINISHED = 20
|
||||
COMPL_NOFINISHED = 30
|
||||
DELETED = 40
|
||||
DURATION_ERROR = 50
|
||||
EXPIRES = 60
|
||||
NOVALID_BUTTON = 70
|
||||
NO_PROVIDER = 80
|
||||
INTERRUPTED = 90
|
||||
IN_PROCESS = 100
|
||||
PINCODE_NOOK = 110
|
||||
PINCODE_OK = 120
|
||||
SYNTH_ERROR = 130
|
||||
USER = 140
|
||||
|
||||
CHOICES = (
|
||||
(ATTEMPTS_EXC, "attempts_exc"),
|
||||
(COMPL_FINISHED, "compl_finished"),
|
||||
(COMPL_NOFINISHED, "compl_nofinished"),
|
||||
(DELETED, "deleted"),
|
||||
(DURATION_ERROR, "duration_error"),
|
||||
(EXPIRES, "expires"),
|
||||
(NOVALID_BUTTON, "novalid_button"),
|
||||
(NO_PROVIDER, "no_provider"),
|
||||
(INTERRUPTED, "interrupted"),
|
||||
(IN_PROCESS, "in_process"),
|
||||
(PINCODE_NOOK, "pincode_nook"),
|
||||
(SYNTH_ERROR, "synth_error"),
|
||||
(USER, "user"),
|
||||
)
|
||||
|
||||
DETERMINANT = {
|
||||
"attempts_exc": ATTEMPTS_EXC,
|
||||
"compl_finished": COMPL_FINISHED,
|
||||
"deleted": DELETED,
|
||||
"duration_error": DURATION_ERROR,
|
||||
"expires": EXPIRES,
|
||||
"novalid_button": NOVALID_BUTTON,
|
||||
"no_provider": NO_PROVIDER,
|
||||
"interrupted": INTERRUPTED,
|
||||
"in_process": IN_PROCESS,
|
||||
"pincode_nook": PINCODE_NOOK,
|
||||
"synth_error": SYNTH_ERROR,
|
||||
"user": USER,
|
||||
}
|
||||
|
||||
|
||||
class ZvonokPhoneCall(ProviderPhoneCall, models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
status = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
choices=ZvonokCallStatuses.CHOICES,
|
||||
)
|
||||
|
||||
call_id = models.CharField(
|
||||
blank=True,
|
||||
max_length=50,
|
||||
)
|
||||
|
||||
campaign_id = models.CharField(
|
||||
max_length=50,
|
||||
)
|
||||
150
engine/apps/zvonok/phone_provider.py
Normal file
150
engine/apps/zvonok/phone_provider.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import logging
|
||||
from random import randint
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.base.utils import live_settings
|
||||
from apps.phone_notifications.exceptions import FailedToMakeCall, FailedToStartVerification
|
||||
from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags
|
||||
from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall
|
||||
|
||||
ZVONOK_CALL_URL = "https://zvonok.com/manager/cabapi_external/api/v1/phones/call/"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZvonokPhoneProvider(PhoneProvider):
|
||||
"""
|
||||
ZvonokPhoneProvider is an implementation of phone provider which supports only voice calls (zvonok.com).
|
||||
"""
|
||||
|
||||
def make_notification_call(self, number: str, message: str) -> ZvonokPhoneCall:
|
||||
speaker = None
|
||||
body = None
|
||||
|
||||
if live_settings.ZVONOK_AUDIO_ID:
|
||||
message = f'<audio id="{live_settings.ZVONOK_AUDIO_ID}"/>'
|
||||
else:
|
||||
speaker = live_settings.ZVONOK_SPEAKER_ID
|
||||
|
||||
try:
|
||||
response = self._call_create(number, message, speaker)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if not body:
|
||||
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, empty body")
|
||||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}, empty body")
|
||||
call_id = body.get("call_id")
|
||||
|
||||
if not call_id:
|
||||
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, missing call id")
|
||||
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
|
||||
|
||||
logger.info(f"ZvonokPhoneProvider.make_notification_call: success, call_id {call_id}")
|
||||
|
||||
return ZvonokPhoneCall(
|
||||
status=ZvonokCallStatuses.IN_PROCESS,
|
||||
call_id=call_id,
|
||||
campaign_id=live_settings.ZVONOK_CAMPAIGN_ID,
|
||||
)
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed {http_err}")
|
||||
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed {err}")
|
||||
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}")
|
||||
|
||||
def make_call(self, number: str, message: str):
|
||||
body = None
|
||||
speaker = live_settings.ZVONOK_SPEAKER_ID
|
||||
|
||||
try:
|
||||
response = self._call_create(number, message, speaker)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if not body:
|
||||
logger.error(f"ZvonokPhoneProvider.make_call: failed, empty body")
|
||||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}, empty body")
|
||||
|
||||
call_id = body.get("call_id")
|
||||
|
||||
if not call_id:
|
||||
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
|
||||
logger.info(f"ZvonokPhoneProvider.make_call: success, call_id {call_id}")
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_call: failed {http_err}")
|
||||
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_call: failed {err}")
|
||||
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}")
|
||||
|
||||
def _call_create(self, number: str, text: str, speaker: Optional[str] = None):
|
||||
params = {
|
||||
"public_key": live_settings.ZVONOK_API_KEY,
|
||||
"campaign_id": live_settings.ZVONOK_CAMPAIGN_ID,
|
||||
"phone": number,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
if speaker:
|
||||
params["speaker"] = speaker
|
||||
|
||||
return requests.post(ZVONOK_CALL_URL, params=params)
|
||||
|
||||
def _get_graceful_msg(self, body, number):
|
||||
if body:
|
||||
status = body.get("status")
|
||||
data = body.get("data")
|
||||
if status == "error" and data:
|
||||
return f"Failed make call to {number} with error: {data}"
|
||||
return f"Failed make call to {number}"
|
||||
|
||||
def make_verification_call(self, number: str):
|
||||
code = str(randint(100000, 999999))
|
||||
cache.set(self._cache_key(number), code, timeout=10 * 60)
|
||||
codewspaces = " ".join(code)
|
||||
|
||||
body = None
|
||||
speaker = live_settings.ZVONOK_SPEAKER_ID
|
||||
|
||||
try:
|
||||
response = self._call_create(number, f"Your verification code is {codewspaces}", speaker)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if not body:
|
||||
logger.error(f"ZvonokPhoneProvider.make_verification_call: failed, empty body")
|
||||
raise FailedToMakeCall(graceful_msg=f"Failed make verification call to {number}, empty body")
|
||||
|
||||
call_id = body.get("call_id")
|
||||
if not call_id:
|
||||
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number))
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_verification_call: failed {http_err}")
|
||||
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number))
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
|
||||
logger.error(f"ZvonokPhoneProvider.make_verification_call: failed {err}")
|
||||
raise FailedToStartVerification(graceful_msg=f"Failed make verification call to {number}")
|
||||
|
||||
def finish_verification(self, number, code):
|
||||
has = cache.get(self._cache_key(number))
|
||||
if has is not None and has == code:
|
||||
return number
|
||||
else:
|
||||
return None
|
||||
|
||||
def _cache_key(self, number):
|
||||
return f"zvonok_provider_{number}"
|
||||
|
||||
@property
|
||||
def flags(self) -> ProviderFlags:
|
||||
return ProviderFlags(
|
||||
configured=True,
|
||||
test_sms=False,
|
||||
test_call=True,
|
||||
verification_call=True,
|
||||
verification_sms=False,
|
||||
)
|
||||
86
engine/apps/zvonok/status_callback.py
Normal file
86
engine/apps/zvonok/status_callback.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from apps.alerts.constants import ActionSource
|
||||
from apps.alerts.signals import user_notification_action_triggered_signal
|
||||
from apps.base.utils import live_settings
|
||||
from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def update_zvonok_call_status(call_id: str, call_status: str, user_choice: Optional[str] = None):
|
||||
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
|
||||
|
||||
status_code = ZvonokCallStatuses.DETERMINANT.get(call_status)
|
||||
if status_code is None:
|
||||
logger.warning(f"zvonok.update_zvonok_call_status: unexpected status call_id={call_id} status={call_status}")
|
||||
return
|
||||
|
||||
zvonok_phone_call = ZvonokPhoneCall.objects.filter(call_id=call_id).first()
|
||||
if zvonok_phone_call is None:
|
||||
logger.warning(f"zvonok.update_zvonok_call_status: zvonok_phone_call not found call_id={call_id}")
|
||||
return
|
||||
|
||||
logger.info(f"zvonok.update_zvonok_call_status: found zvonok_phone_call call_id={call_id}")
|
||||
|
||||
zvonok_phone_call.status = status_code
|
||||
zvonok_phone_call.save(update_fields=["status"])
|
||||
phone_call_record = zvonok_phone_call.phone_call_record
|
||||
|
||||
if phone_call_record is None:
|
||||
logger.warning(
|
||||
f"zvonok.update_zvonok_call_status: zvonok_phone_call has no phone_call record call_id={call_id} "
|
||||
f"status={call_status}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"zvonok.update_zvonok_call_status: found phone_call_record id={phone_call_record.id} "
|
||||
f"call_id={call_id} status={call_status}"
|
||||
)
|
||||
log_record_type = None
|
||||
log_record_error_code = None
|
||||
|
||||
success_statuses = [ZvonokCallStatuses.USER, ZvonokCallStatuses.COMPL_FINISHED]
|
||||
|
||||
if status_code in success_statuses:
|
||||
log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
|
||||
else:
|
||||
log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
|
||||
log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED
|
||||
|
||||
if log_record_type is not None:
|
||||
log_record = UserNotificationPolicyLogRecord(
|
||||
type=log_record_type,
|
||||
notification_error_code=log_record_error_code,
|
||||
author=phone_call_record.receiver,
|
||||
notification_policy=phone_call_record.notification_policy,
|
||||
alert_group=phone_call_record.represents_alert_group,
|
||||
notification_step=phone_call_record.notification_policy.step
|
||||
if phone_call_record.notification_policy
|
||||
else None,
|
||||
notification_channel=phone_call_record.notification_policy.notify_by
|
||||
if phone_call_record.notification_policy
|
||||
else None,
|
||||
)
|
||||
log_record.save()
|
||||
logger.info(
|
||||
f"zvonok.update_zvonok_call_status: created log_record log_record_id={log_record.id} "
|
||||
f"type={log_record_type}"
|
||||
)
|
||||
|
||||
user_notification_action_triggered_signal.send(sender=update_zvonok_call_status, log_record=log_record)
|
||||
|
||||
if user_choice and user_choice == live_settings.ZVONOK_POSTBACK_USER_CHOICE_ACK:
|
||||
alert_group = phone_call_record.represents_alert_group
|
||||
user = phone_call_record.receiver
|
||||
logger.info(
|
||||
f"zvonok.update_zvonok_call_status: processing user choice"
|
||||
f" phone_call_record id={phone_call_record.id} zvonok_phone_call_id={call_id} "
|
||||
f"alert_group_id={alert_group.id} user_id={user.id}"
|
||||
)
|
||||
|
||||
alert_group.acknowledge_by_user(user, action_source=ActionSource.PHONE)
|
||||
7
engine/apps/zvonok/urls.py
Normal file
7
engine/apps/zvonok/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import CallStatusCallback
|
||||
|
||||
urlpatterns = [
|
||||
path("call_status_events", CallStatusCallback.as_view(), name="call_status_events"),
|
||||
]
|
||||
52
engine/apps/zvonok/views.py
Normal file
52
engine/apps/zvonok/views.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from django.apps import apps
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.base.utils import live_settings
|
||||
|
||||
from .status_callback import update_zvonok_call_status
|
||||
|
||||
|
||||
class AllowOnlyZvonok(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
call_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
|
||||
if not call_id:
|
||||
return False
|
||||
|
||||
campaign_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CAMPAIGN_ID)
|
||||
if not campaign_id:
|
||||
return False
|
||||
|
||||
if campaign_id != live_settings.ZVONOK_CAMPAIGN_ID:
|
||||
return False
|
||||
ZvonokCall = apps.get_model("zvonok", "ZvonokPhoneCall")
|
||||
call = ZvonokCall.objects.filter(call_id=call_id, campaign_id=campaign_id).first()
|
||||
if call:
|
||||
return self.validate_request(request)
|
||||
return False
|
||||
|
||||
def validate_request(self, request):
|
||||
if request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Receive Call Status from Zvonok
|
||||
class CallStatusCallback(APIView):
|
||||
permission_classes = [AllowOnlyZvonok]
|
||||
|
||||
def get(self, request):
|
||||
self._handle_call_status(request)
|
||||
return Response(data="", status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def post(self, request):
|
||||
self._handle_call_status(request)
|
||||
return Response(data="", status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def _handle_call_status(self, request):
|
||||
call_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
|
||||
call_status = request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS)
|
||||
user_choice = request.GET.get(live_settings.ZVONOK_POSTBACK_USER_CHOICE)
|
||||
update_zvonok_call_status(call_id=call_id, call_status=call_status, user_choice=user_choice)
|
||||
|
|
@ -65,6 +65,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
|
|||
if settings.IS_OPEN_SOURCE:
|
||||
urlpatterns += [
|
||||
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
|
||||
path("zvonok/", include("apps.zvonok.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ INSTALLED_ONCALL_INTEGRATIONS = [
|
|||
]
|
||||
|
||||
if IS_OPEN_SOURCE:
|
||||
INSTALLED_APPS += ["apps.oss_installation"] # noqa
|
||||
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa
|
||||
|
||||
CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa
|
||||
"task": "apps.oss_installation.tasks.send_usage_stats_report",
|
||||
|
|
@ -724,5 +724,16 @@ PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "")
|
|||
PHONE_PROVIDERS = {
|
||||
"twilio": "apps.twilioapp.phone_provider.TwilioPhoneProvider",
|
||||
# "simple": "apps.phone_notifications.simple_phone_provider.SimplePhoneProvider",
|
||||
"zvonok": "apps.zvonok.phone_provider.ZvonokPhoneProvider",
|
||||
}
|
||||
PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default="twilio")
|
||||
|
||||
ZVONOK_API_KEY = os.getenv("ZVONOK_API_KEY", None)
|
||||
ZVONOK_CAMPAIGN_ID = os.getenv("ZVONOK_CAMPAIGN_ID", None)
|
||||
ZVONOK_AUDIO_ID = os.getenv("ZVONOK_AUDIO_ID", None)
|
||||
ZVONOK_SPEAKER_ID = os.getenv("ZVONOK_SPEAKER_ID", "Salli")
|
||||
ZVONOK_POSTBACK_CALL_ID = os.getenv("ZVONOK_POSTBACK_CALL_ID", "call_id")
|
||||
ZVONOK_POSTBACK_CAMPAIGN_ID = os.getenv("ZVONOK_POSTBACK_CAMPAIGN_ID", "campaign_id")
|
||||
ZVONOK_POSTBACK_STATUS = os.getenv("ZVONOK_POSTBACK_STATUS", "status")
|
||||
ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None)
|
||||
ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None)
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
# copy this file to ./.env and fill out the values according to your local setup
|
||||
|
||||
# for integration test purposes
|
||||
BASE_URL=http://localhost:3000
|
||||
ONCALL_API_URL=http://host.docker.internal:8080/
|
||||
GRAFANA_USERNAME=oncall
|
||||
GRAFANA_PASSWORD=oncall
|
||||
IS_OPEN_SOURCE=True
|
||||
1
grafana-plugin/integration-tests/.auth/.gitignore
vendored
Normal file
1
grafana-plugin/integration-tests/.auth/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.json
|
||||
9
grafana-plugin/integration-tests/.env.example
Normal file
9
grafana-plugin/integration-tests/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
BASE_URL=http://localhost:3000
|
||||
ONCALL_API_URL=http://host.docker.internal:8080/
|
||||
GRAFANA_VIEWER_USERNAME=viewer
|
||||
GRAFANA_VIEWER_PASSWORD=viewer
|
||||
GRAFANA_EDITOR_USERNAME=editor
|
||||
GRAFANA_EDITOR_PASSWORD=editor
|
||||
GRAFANA_ADMIN_USERNAME=oncall
|
||||
GRAFANA_ADMIN_PASSWORD=oncall
|
||||
IS_OPEN_SOURCE=True
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
import { test } from '@playwright/test';
|
||||
import { test } from '../fixtures';
|
||||
import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup';
|
||||
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('we can create an oncall schedule + receive an alert', async ({ page }) => {
|
||||
test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => {
|
||||
// this test does a lot of stuff, lets give it adequate time to do its thing
|
||||
test.slow();
|
||||
|
||||
const { page, userName } = adminRolePage;
|
||||
const escalationChainName = generateRandomValue();
|
||||
const integrationName = generateRandomValue();
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
await createEscalationChain(
|
||||
page,
|
||||
escalationChainName,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { GRAFANA_USERNAME } from '../utils/constants';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
|
||||
|
|
@ -7,14 +6,15 @@ import { waitForSms } from '../utils/phone';
|
|||
import { configureUserNotificationSettings, verifyUserPhoneNumber } from '../utils/userSettings';
|
||||
|
||||
// TODO: enable once we've signed up for a MailSlurp account to receieve SMSes
|
||||
test.skip('we can verify our phone number + receive an SMS alert', async ({ page }) => {
|
||||
test.skip('we can verify our phone number + receive an SMS alert', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
const escalationChainName = generateRandomValue();
|
||||
const integrationName = generateRandomValue();
|
||||
|
||||
await verifyUserPhoneNumber(page);
|
||||
await configureUserNotificationSettings(page, 'SMS');
|
||||
|
||||
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, GRAFANA_USERNAME);
|
||||
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
|
||||
await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName);
|
||||
|
||||
// wait for the SMS alert notification to arrive
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, Page } from '@playwright/test';
|
||||
import { test, expect, Page } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createEscalationChain } from '../utils/escalationChain';
|
||||
|
||||
|
|
@ -16,7 +16,9 @@ const assertEscalationChainSearchWorks = async (
|
|||
};
|
||||
|
||||
// TODO: add tests for the new filtering. Commented out as this search doesn't exist anymore
|
||||
test.skip('searching allows case-insensitive partial matches', async ({ page }) => {
|
||||
test.skip('searching allows case-insensitive partial matches', async ({ adminRolePage }) => {
|
||||
const { page } = adminRolePage;
|
||||
|
||||
const escalationChainName = `${generateRandomValue()} ${generateRandomValue()}`;
|
||||
const [firstHalf, secondHalf] = escalationChainName.split(' ');
|
||||
|
||||
|
|
|
|||
53
grafana-plugin/integration-tests/fixtures.ts
Normal file
53
grafana-plugin/integration-tests/fixtures.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { test as base, Page } from '@playwright/test';
|
||||
|
||||
import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants';
|
||||
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
|
||||
|
||||
export class BaseRolePage {
|
||||
page: Page;
|
||||
userName: string;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
}
|
||||
|
||||
class ViewerRolePage extends BaseRolePage {
|
||||
userName = GRAFANA_VIEWER_USERNAME;
|
||||
}
|
||||
|
||||
class EditorRolePage extends BaseRolePage {
|
||||
userName = GRAFANA_EDITOR_USERNAME;
|
||||
}
|
||||
|
||||
class AdminRolePage extends BaseRolePage {
|
||||
userName = GRAFANA_ADMIN_USERNAME;
|
||||
}
|
||||
|
||||
type Fixtures = {
|
||||
viewerRolePage: ViewerRolePage;
|
||||
editorRolePage: EditorRolePage;
|
||||
adminRolePage: AdminRolePage;
|
||||
};
|
||||
|
||||
export * from '@playwright/test';
|
||||
export const test = base.extend<Fixtures>({
|
||||
viewerRolePage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: VIEWER_USER_STORAGE_STATE });
|
||||
const page = new ViewerRolePage(await context.newPage());
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
editorRolePage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: EDITOR_USER_STORAGE_STATE });
|
||||
const page = new EditorRolePage(await context.newPage());
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
adminRolePage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: ADMIN_USER_STORAGE_STATE });
|
||||
const page = new AdminRolePage(await context.newPage());
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
|
@ -1,37 +1,57 @@
|
|||
import { test as setup, chromium, FullConfig, expect, Page, BrowserContext, APIResponse } from '@playwright/test';
|
||||
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
|
||||
|
||||
import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants';
|
||||
import GrafanaAPIClient from './utils/clients/grafana';
|
||||
import {
|
||||
GRAFANA_ADMIN_PASSWORD,
|
||||
GRAFANA_ADMIN_USERNAME,
|
||||
GRAFANA_EDITOR_PASSWORD,
|
||||
GRAFANA_EDITOR_USERNAME,
|
||||
GRAFANA_VIEWER_PASSWORD,
|
||||
GRAFANA_VIEWER_USERNAME,
|
||||
IS_CLOUD,
|
||||
IS_OPEN_SOURCE,
|
||||
ONCALL_API_URL,
|
||||
} from './utils/constants';
|
||||
import { clickButton, getInputByName } from './utils/forms';
|
||||
import { goToGrafanaPage } from './utils/navigation';
|
||||
import { STORAGE_STATE } from '../playwright.config';
|
||||
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
|
||||
import { OrgRole } from '@grafana/data';
|
||||
|
||||
const IS_CLOUD = !IS_OPEN_SOURCE;
|
||||
const GLOBAL_SETUP_RETRIES = 3;
|
||||
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
|
||||
|
||||
const makeGrafanaLoginRequest = async (browserContext: BrowserContext): Promise<APIResponse> =>
|
||||
browserContext.request.post(`${BASE_URL}/login`, {
|
||||
data: {
|
||||
user: GRAFANA_USERNAME,
|
||||
password: GRAFANA_PASSWORD,
|
||||
},
|
||||
});
|
||||
type UserCreationSettings = {
|
||||
adminAuthedRequest: APIRequestContext;
|
||||
role: OrgRole;
|
||||
};
|
||||
|
||||
const pollGrafanaInstanceUntilItIsHealthy = async (browserContext: BrowserContext): Promise<boolean> => {
|
||||
console.log('Polling the grafana instance to make sure it is healthy');
|
||||
|
||||
const res = await makeGrafanaLoginRequest(browserContext);
|
||||
|
||||
if (!res.ok()) {
|
||||
console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
return pollGrafanaInstanceUntilItIsHealthy(browserContext);
|
||||
const generateLoginStorageStateAndOptionallCreateUser = async (
|
||||
config: FullConfig,
|
||||
userName: string,
|
||||
password: string,
|
||||
storageStateFileLocation: string,
|
||||
userCreationSettings?: UserCreationSettings,
|
||||
closeContext = false
|
||||
): Promise<BrowserContext> => {
|
||||
if (userCreationSettings !== undefined && IS_OPEN_SOURCE) {
|
||||
const { adminAuthedRequest, role } = userCreationSettings;
|
||||
await grafanaApiClient.idempotentlyCreateUserWithRole(adminAuthedRequest, userName, password, role);
|
||||
}
|
||||
console.log('Grafana instance is available');
|
||||
return true;
|
||||
|
||||
const { headless } = config.projects[0]!.use;
|
||||
const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
|
||||
const browserContext = await browser.newContext();
|
||||
|
||||
await grafanaApiClient.login(browserContext.request, userName, password);
|
||||
await browserContext.storageState({ path: storageStateFileLocation });
|
||||
|
||||
if (closeContext) {
|
||||
await browserContext.close();
|
||||
}
|
||||
return browserContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* go to config page and wait for plugin icon to be available on left-hand navigation
|
||||
go to config page and wait for plugin icon to be available on left-hand navigation
|
||||
*/
|
||||
const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
||||
/**
|
||||
|
|
@ -66,55 +86,7 @@ const configureOnCallPlugin = async (page: Page): Promise<void> => {
|
|||
* Borrowed from our friends on the Incident team
|
||||
* https://github.com/grafana/incident/blob/main/plugin/e2e/global-setup.ts
|
||||
*/
|
||||
const globalSetup = async (config: FullConfig): Promise<void> => {
|
||||
const { headless } = config.projects[0]!.use;
|
||||
const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
|
||||
const browserContext = await browser.newContext();
|
||||
|
||||
if (IS_CLOUD) {
|
||||
/**
|
||||
* check that the grafana instance is available. If HTTP 503 is returned it means the
|
||||
* instance is currently unavailable. Poll until it is available
|
||||
*/
|
||||
await pollGrafanaInstanceUntilItIsHealthy(browserContext);
|
||||
}
|
||||
|
||||
const res = await makeGrafanaLoginRequest(browserContext);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
await browserContext.storageState({ path: STORAGE_STATE });
|
||||
|
||||
// make sure the plugin has been configured
|
||||
const page = await browserContext.newPage();
|
||||
|
||||
if (IS_OPEN_SOURCE) {
|
||||
// plugin configuration can safely be skipped for cloud environments
|
||||
await configureOnCallPlugin(page);
|
||||
}
|
||||
|
||||
await browserContext.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Let's retry global setup, in the event that it fails due to an oncall-engine/oncall-celery backend error.
|
||||
* Sometimes the sync endpoint will randomly return HTTP 500.
|
||||
* See here for an example CI job which failed global setup
|
||||
* https://github.com/grafana/oncall/actions/runs/5062712137/jobs/9088529416#step:19:2536
|
||||
*
|
||||
* References on retrying playwright global setup
|
||||
* https://github.com/microsoft/playwright/discussions/11371
|
||||
*/
|
||||
const globalSetupWithRetries = async (config: FullConfig): Promise<void> => {
|
||||
for (let i = 0; i < GLOBAL_SETUP_RETRIES - 1; i++) {
|
||||
try {
|
||||
return await globalSetup(config);
|
||||
} catch (e) {}
|
||||
}
|
||||
// One last time, throwing an error if it fails.
|
||||
await globalSetup(config);
|
||||
};
|
||||
|
||||
setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
|
||||
setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => {
|
||||
/**
|
||||
* Unconditionally marks the setup as "slow", giving it triple the default timeout.
|
||||
* This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable
|
||||
|
|
@ -122,5 +94,47 @@ setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
|
|||
*/
|
||||
setup.slow();
|
||||
|
||||
await globalSetupWithRetries(config);
|
||||
if (IS_CLOUD) {
|
||||
await grafanaApiClient.pollInstanceUntilItIsHealthy(request);
|
||||
}
|
||||
|
||||
const adminBrowserContext = await generateLoginStorageStateAndOptionallCreateUser(
|
||||
config,
|
||||
GRAFANA_ADMIN_USERNAME,
|
||||
GRAFANA_ADMIN_PASSWORD,
|
||||
ADMIN_USER_STORAGE_STATE
|
||||
);
|
||||
const adminPage = await adminBrowserContext.newPage();
|
||||
const { request: adminAuthedRequest } = adminBrowserContext;
|
||||
|
||||
await generateLoginStorageStateAndOptionallCreateUser(
|
||||
config,
|
||||
GRAFANA_EDITOR_USERNAME,
|
||||
GRAFANA_EDITOR_PASSWORD,
|
||||
EDITOR_USER_STORAGE_STATE,
|
||||
{
|
||||
adminAuthedRequest,
|
||||
role: OrgRole.Editor,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await generateLoginStorageStateAndOptionallCreateUser(
|
||||
config,
|
||||
GRAFANA_VIEWER_USERNAME,
|
||||
GRAFANA_VIEWER_PASSWORD,
|
||||
VIEWER_USER_STORAGE_STATE,
|
||||
{
|
||||
adminAuthedRequest,
|
||||
role: OrgRole.Viewer,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
if (IS_OPEN_SOURCE) {
|
||||
// plugin configuration can safely be skipped for cloud environments
|
||||
await configureOnCallPlugin(adminPage);
|
||||
}
|
||||
|
||||
await adminBrowserContext.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { openCreateIntegrationModal } from '../utils/integrations';
|
||||
|
||||
test('integrations have unique names', async ({ page }) => {
|
||||
test('integrations have unique names', async ({ adminRolePage }) => {
|
||||
const { page } = adminRolePage;
|
||||
await openCreateIntegrationModal(page);
|
||||
|
||||
const integrationNames = await page.getByTestId('integration-display-name').allInnerTexts();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { clickButton, generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
test('default dates in override creation modal are correct', async ({ page }) => {
|
||||
test('default dates in override creation modal are correct', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
await clickButton({ page, buttonText: 'Add override' });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
|
||||
test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
/**
|
||||
* this page.reload() call is a hack to temporarily get around this issue
|
||||
|
|
|
|||
34
grafana-plugin/integration-tests/users/viewUsers.test.ts
Normal file
34
grafana-plugin/integration-tests/users/viewUsers.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { test, expect, Page } from '../fixtures';
|
||||
import { goToOnCallPage } from '../utils/navigation';
|
||||
|
||||
test.describe('view list of users', () => {
|
||||
const testFlow = async (page: Page, isAllowedToView = true): Promise<void> => {
|
||||
await goToOnCallPage(page, 'users');
|
||||
|
||||
if (isAllowedToView) {
|
||||
const usersTableElement = page.getByTestId('users-table');
|
||||
await usersTableElement.waitFor({ state: 'visible' });
|
||||
|
||||
const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents();
|
||||
expect(userRowsContext.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message');
|
||||
await missingPermissionsMessageElement.waitFor({ state: 'visible' });
|
||||
|
||||
const missingPermissionMessage = await missingPermissionsMessageElement.textContent();
|
||||
expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/);
|
||||
}
|
||||
};
|
||||
|
||||
test('admin is allowed to', async ({ adminRolePage }) => {
|
||||
await testFlow(adminRolePage.page);
|
||||
});
|
||||
|
||||
test('editor is allowed to', async ({ editorRolePage }) => {
|
||||
await testFlow(editorRolePage.page);
|
||||
});
|
||||
|
||||
test('viewer is not allowed to', async ({ viewerRolePage }) => {
|
||||
await testFlow(viewerRolePage.page, false);
|
||||
});
|
||||
});
|
||||
116
grafana-plugin/integration-tests/utils/clients/grafana.ts
Normal file
116
grafana-plugin/integration-tests/utils/clients/grafana.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { OrgRole } from '@grafana/data';
|
||||
import { expect, APIRequestContext } from '@playwright/test';
|
||||
|
||||
import { BASE_URL } from '../constants';
|
||||
|
||||
type UsersLookupResponse = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
type CreateUserResponse = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
class GrafanaApiException extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export default class GrafanaAPIClient {
|
||||
userName: string;
|
||||
password: string;
|
||||
|
||||
constructor(userName: string, password: string) {
|
||||
this.userName = userName;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
get requestHeaders() {
|
||||
const base64encodedCredentials = Buffer.from(`${this.userName}:${this.password}`).toString('base64');
|
||||
return {
|
||||
Authorization: `Basic ${base64encodedCredentials}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* check that the grafana instance is available. If HTTP 503 is returned it means the
|
||||
* instance is currently unavailable. Poll until it is available
|
||||
*/
|
||||
pollInstanceUntilItIsHealthy = async (request: APIRequestContext): Promise<boolean> => {
|
||||
console.log('Polling the grafana instance to make sure it is healthy');
|
||||
|
||||
const res = await request.get(`${BASE_URL}/api/health`);
|
||||
|
||||
if (!res.ok()) {
|
||||
console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
return this.pollInstanceUntilItIsHealthy(request);
|
||||
}
|
||||
console.log('Grafana instance is available');
|
||||
return true;
|
||||
};
|
||||
|
||||
getUserIdByUsername = async (request: APIRequestContext, userName: string): Promise<number> => {
|
||||
const res = await request.get(`${BASE_URL}/api/users/lookup?loginOrEmail=${userName}`, {
|
||||
headers: this.requestHeaders,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const responseData: UsersLookupResponse = await res.json();
|
||||
return responseData.id;
|
||||
};
|
||||
|
||||
updateUserRole = async (request: APIRequestContext, userId: number, role: OrgRole): Promise<void> => {
|
||||
const res = await request.patch(`${BASE_URL}/api/org/users/${userId}`, {
|
||||
data: { role },
|
||||
headers: this.requestHeaders,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Should return one of the following two responses:
|
||||
* - HTTP 200 - user successfully created
|
||||
* - HTTP 412 - user w/ this username already exists (fine to ignore this)
|
||||
*/
|
||||
idempotentlyCreateUserWithRole = async (
|
||||
request: APIRequestContext,
|
||||
userName: string,
|
||||
password: string,
|
||||
role: OrgRole
|
||||
) => {
|
||||
const res = await request.post(`${BASE_URL}/api/admin/users`, {
|
||||
data: {
|
||||
name: `e2e user - ${userName}`,
|
||||
login: userName,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
let userId: number;
|
||||
const responseCode = res.status();
|
||||
|
||||
if (responseCode === 200) {
|
||||
// user was just created
|
||||
const respJson: CreateUserResponse = await res.json();
|
||||
userId = respJson.id;
|
||||
} else if (responseCode == 412) {
|
||||
// user already exists, go fetch their user id
|
||||
userId = await this.getUserIdByUsername(request, userName);
|
||||
} else {
|
||||
throw new GrafanaApiException(
|
||||
`Received unexpected status code while trying to idempotently create user - HTTP${responseCode}: ${await res.body()}`
|
||||
);
|
||||
}
|
||||
|
||||
await this.updateUserRole(request, userId, role);
|
||||
};
|
||||
|
||||
login = async (request: APIRequestContext, userName: string, password: string) => {
|
||||
const res = await request.post(`${BASE_URL}/login`, {
|
||||
data: { user: userName, password },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker.internal:8080';
|
||||
export const GRAFANA_USERNAME = process.env.GRAFANA_USERNAME || 'oncall';
|
||||
export const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'oncall';
|
||||
export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY;
|
||||
|
||||
export const GRAFANA_VIEWER_USERNAME = process.env.GRAFANA_VIEWER_USERNAME || 'viewer';
|
||||
export const GRAFANA_VIEWER_PASSWORD = process.env.GRAFANA_VIEWER_PASSWORD || 'viewer';
|
||||
export const GRAFANA_EDITOR_USERNAME = process.env.GRAFANA_EDITOR_USERNAME || 'editor';
|
||||
export const GRAFANA_EDITOR_PASSWORD = process.env.GRAFANA_EDITOR_PASSWORD || 'editor';
|
||||
export const GRAFANA_ADMIN_USERNAME = process.env.GRAFANA_ADMIN_USERNAME || 'oncall';
|
||||
export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'oncall';
|
||||
|
||||
export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
|
||||
export const IS_CLOUD = !IS_OPEN_SOURCE;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Page } from '@playwright/test';
|
||||
import { GRAFANA_USERNAME } from './constants';
|
||||
import { clickButton, fillInInput, selectDropdownValue, selectValuePickerValue } from './forms';
|
||||
import { clickButton, fillInInput, selectDropdownValue } from './forms';
|
||||
import { goToOnCallPage } from './navigation';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const createOnCallSchedule = async (page: Page, scheduleName: string): Promise<void> => {
|
||||
export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise<void> => {
|
||||
// go to the schedules page
|
||||
await goToOnCallPage(page, 'schedules');
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ export const createOnCallSchedule = async (page: Page, scheduleName: string): Pr
|
|||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Add user',
|
||||
value: GRAFANA_USERNAME,
|
||||
value: userName,
|
||||
});
|
||||
|
||||
await clickButton({ page, buttonText: 'Create' });
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { devices } from '@playwright/test';
|
|||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
require('dotenv').config();
|
||||
require('dotenv').config({ path: path.resolve(process.cwd(), 'integration-tests/.env') });
|
||||
|
||||
export const STORAGE_STATE = path.join(__dirname, 'integration-tests/storageState.json');
|
||||
export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/viewer.json');
|
||||
export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/editor.json');
|
||||
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/admin.json');
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
|
|
@ -62,7 +64,6 @@ const config: PlaywrightTestConfig = {
|
|||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
|
@ -70,7 +71,6 @@ const config: PlaywrightTestConfig = {
|
|||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
|
@ -78,7 +78,6 @@ const config: PlaywrightTestConfig = {
|
|||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
storageState: STORAGE_STATE,
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
|||
{content}
|
||||
</Block>
|
||||
</div>
|
||||
{store.hasFeature(AppFeature.MobileTestPush) && mobileAppIsCurrentlyConnected && isCurrentUser && (
|
||||
{mobileAppIsCurrentlyConnected && isCurrentUser && (
|
||||
<div className={cx('notification-buttons')}>
|
||||
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
</div>
|
||||
|
||||
<GTable
|
||||
data-testid="users-table"
|
||||
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
|
|
@ -246,6 +247,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
profile
|
||||
</>
|
||||
}
|
||||
data-testid="view-users-missing-permission-message"
|
||||
severity="info"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,4 @@ export enum AppFeature {
|
|||
CloudConnection = 'grafana_cloud_connection',
|
||||
WebSchedules = 'web_schedules',
|
||||
Webhooks2 = 'webhooks2',
|
||||
MobileTestPush = 'mobile_test_push',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue