diff --git a/CHANGELOG.md b/CHANGELOG.md index 969b7bde..0d2d3f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.1.5 (2022-11-24) + +### Fixed + +- UI bug fixes for Grafana 9.3 ([#860](https://github.com/grafana/oncall/pull/860)) +- Bug fix for saving source link template ([#898](https://github.com/grafana/oncall/pull/898)) + + ## v1.1.4 (2022-11-23) ### Fixed diff --git a/Makefile b/Makefile index b60a4be8..79f3fbaa 100644 --- a/Makefile +++ b/Makefile @@ -62,12 +62,6 @@ define run_engine_docker_command endef # touch SQLITE_DB_FILE if it does not exist and DB is eqaul to SQLITE_PROFILE -# -# hostess installation (crossplatform/idempotent modification of /etc/hosts file) -# see here (https://github.com/cbednarski/hostess#installation) for docs -# basically this is needed because oncall api has been configured locally to communicate w/ grafana @ -# http://grafana:3000. This becomes a problem in certain parts of OnCall where we generate "public" URLs -# and the user tries to access them via their browser. start: ifeq ($(DB),$(SQLITE_PROFILE)) @if [ ! -f $(SQLITE_DB_FILE) ]; then \ @@ -75,17 +69,6 @@ ifeq ($(DB),$(SQLITE_PROFILE)) fi endif - @if [ ! -x "$$(command -v hostess)" ]; then \ - echo "installing hostess"; \ - git clone https://github.com/cbednarski/hostess "${HOME}/hostess"; \ - cd "${HOME}/hostess"; \ - make install; \ - fi - - @if ! hostess has grafana; then \ - sudo hostess add grafana 127.0.0.1; \ - fi - $(call run_docker_compose_command,up --remove-orphans -d) init: @@ -166,3 +149,6 @@ run-backend-celery: backend-command: $(call backend_command,$(CMD)) + +backend-manage-command: + $(call backend_command,python manage.py $(CMD)) diff --git a/dev/README.md b/dev/README.md index 7e063dc1..9f858e3c 100644 --- a/dev/README.md +++ b/dev/README.md @@ -22,7 +22,7 @@ Related: [How to develop integrations](/engine/config_integrations/README.md) By default everything runs inside Docker. These options can be modified via the [`COMPOSE_PROFILES`](#compose_profiles) environment variable. -1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose v2. For instructions on how to enable this (if you haven't already done so), see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/). +1. Firstly, ensure that you have `docker` [installed](https://docs.docker.com/get-docker/) and running on your machine. **NOTE**: the `docker-compose-developer.yml` file uses some syntax/features that are only supported by Docker Compose v2. For instructions on how to enable this (if you haven't already done so), see [here](https://www.docker.com/blog/announcing-compose-v2-general-availability/). Ensure you have Docker Compose version 2.10 or above installed - update instructions are [here](https://docs.docker.com/compose/install/linux/). 2. Run `make init start`. By default this will run everything in Docker, using SQLite as the database and Redis as the message broker/cache. See [Running in Docker](#running-in-docker) below for more details on how to swap out/disable which components are run in Docker. 3. Open Grafana in a browser [here](http://localhost:3000/plugins/grafana-oncall-app) (login: `oncall`, password: `oncall`). 4. You should now see the OnCall plugin configuration page. Fill out the configuration options as follows: @@ -104,6 +104,10 @@ make dbshell # opens a DB shell make exec-engine # exec into engine container's bash make test # run backend tests +# run Django's `manage.py` script, passing `$CMD` as arguments. +# e.g. `make backend-manage-command makemigrations` - https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations +make backend-manage-command CMD="..." + # run both frontend and backend linters # may need to run `yarn install` from within `grafana-plugin` to install several `pre-commit` dependencies make lint diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index b76d5734..e8208bbb 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -20,7 +20,19 @@ x-env-files: &oncall-env-files x-env-vars: &oncall-env-vars BROKER_TYPE: ${BROKER_TYPE} - GRAFANA_API_URL: http://grafana:3000 + GRAFANA_API_URL: http://localhost:3000 + +# basically this is needed because the oncall backend containers have been configured to communicate w/ grafana via +# http://localhost:3000 (GRAFANA_API_URL). This URL is used in two scenarios: +# 1. oncall backend -> grafana API communication (happens within docker) +# 2. accessing templated URLs generated by the oncall backend - meant to be accessed via a browser on your host machine +# The alternative is to set GRAFANA_API_URL to http://grafana:3000. However, this would only work in scenario #1 +# as http://grafana:3000 would not be resolvable on the host machine (without modifying /etc/hosts) +# +# by adding this extra_host entry to the oncall backend containers any calls to localhost will get routed to the docker +# gateway, onto the host machine, where localhost:3000 points to grafana +x-extra-hosts: &oncall-extra-hosts + - "localhost:host-gateway" services: oncall_ui: @@ -49,6 +61,7 @@ services: env_file: *oncall-env-files environment: *oncall-env-vars volumes: *oncall-volumes + extra_hosts: *oncall-extra-hosts ports: - "8080:8080" depends_on: @@ -68,6 +81,7 @@ services: env_file: *oncall-env-files environment: *oncall-env-vars volumes: *oncall-volumes + extra_hosts: *oncall-extra-hosts profiles: # no need to start this except from within the Makefile - _engine_commands @@ -81,6 +95,7 @@ services: env_file: *oncall-env-files environment: *oncall-env-vars volumes: *oncall-volumes + extra_hosts: *oncall-extra-hosts depends_on: oncall_db_migration: condition: service_completed_successfully @@ -95,6 +110,7 @@ services: env_file: *oncall-env-files environment: *oncall-env-vars volumes: *oncall-volumes + extra_hosts: *oncall-extra-hosts depends_on: postgres: condition: service_healthy diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 7bd2f03d..b841aa3f 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -5,10 +5,8 @@ from django.conf import settings from django.db import transaction from django.utils import timezone from kombu import uuid as celery_uuid -from push_notifications.models import APNSDevice from apps.alerts.constants import NEXT_ESCALATION_DELAY -from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer from apps.alerts.signals import user_notification_action_triggered_signal from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings @@ -348,47 +346,6 @@ def perform_notification(log_record_pk): notification_channel=notification_channel, notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK, ).save() - - elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: - message = f"{AlertGroupWebRenderer(alert_group).render().get('title', 'Incident')}" - thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" - devices_to_notify = APNSDevice.objects.filter(user_id=user.pk) - devices_to_notify.send_message( - message, - thread_id=thread_id, - category="USER_NEW_INCIDENT", - extra={ - "orgId": f"{alert_group.channel.organization.public_primary_key}", - "orgName": f"{alert_group.channel.organization.stack_slug}", - "incidentId": f"{alert_group.public_primary_key}", - "status": f"{alert_group.status}", - "aps": { - "alert": f"{message}", - "sound": "bingbong.aiff", - }, - }, - ) - - elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: - message = f"{AlertGroupWebRenderer(alert_group).render().get('title', 'Incident')}" - thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" - devices_to_notify = APNSDevice.objects.filter(user_id=user.pk) - devices_to_notify.send_message( - message, - thread_id=thread_id, - category="USER_NEW_INCIDENT", - extra={ - "orgId": f"{alert_group.channel.organization.public_primary_key}", - "orgName": f"{alert_group.channel.organization.stack_slug}", - "incidentId": f"{alert_group.public_primary_key}", - "status": f"{alert_group.status}", - "aps": { - "alert": f"Critical page: {message}", - "interruption-level": "critical", - "sound": "ambulance.aiff", - }, - }, - ) else: try: backend_id = UserNotificationPolicy.NotificationChannel(notification_policy.notify_by).name diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 58d6f349..91823581 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -443,9 +443,9 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode def set_source_link_template(self, value): default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SOURCE_LINK_TEMPLATE[self.instance.integration] if default_template is None or default_template.strip() != value.strip(): - self.instance.source_link = value.strip() + self.instance.source_link_template = value.strip() elif default_template is not None and default_template.strip() == value.strip(): - self.instance.source_link = None + self.instance.source_link_template = None def get_grouping_id_template(self, obj): default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_GROUPING_ID_TEMPLATE[obj.integration] diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index a4a10ccf..16330810 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -268,3 +268,45 @@ def test_preview_alert_receive_channel_backend_templater( assert response.status_code == status.HTTP_200_OK assert response.json() == {"preview": "title: alert!"} + + +@pytest.mark.django_db +def test_update_alert_receive_channel_templates( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + def template_update_func(template): + # set url here to pass *_url templates validation + return "https://grafana.com" + + organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + alert_receive_channel = make_alert_receive_channel( + organization, + messaging_backends_templates={"TESTONLY": {"title": "the-title", "message": "the-message", "image_url": "url"}}, + ) + client = APIClient() + + url = reverse( + "api-internal:alert_receive_channel_template-detail", kwargs={"pk": alert_receive_channel.public_primary_key} + ) + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + existing_templates_data = response.json() + + # leave only templates-related fields + del existing_templates_data["id"] + del existing_templates_data["verbal_name"] + del existing_templates_data["payload_example"] + + new_templates_data = {} + for template_name, template_value in existing_templates_data.items(): + new_templates_data[template_name] = template_update_func(template_value) + + response = client.put(url, format="json", data=new_templates_data, **make_user_auth_headers(user, token)) + + updated_templates_data = response.json() + for template_name, prev_template_value in existing_templates_data.items(): + assert updated_templates_data[template_name] == template_update_func(prev_template_value) diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 3b7f46bf..05d87b75 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -1,6 +1,7 @@ from django.conf import settings from django.urls import include, path, re_path +from apps.mobile_app.views import APNSDeviceAuthorizedViewSet from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path from .views import UserNotificationPolicyView, auth @@ -8,7 +9,6 @@ from .views.alert_group import AlertGroupView from .views.alert_receive_channel import AlertReceiveChannelView from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView from .views.alerts import AlertDetailView -from .views.apns_device import APNSDeviceAuthorizedViewSet from .views.channel_filter import ChannelFilterView from .views.custom_button import CustomButtonView from .views.escalation_chain import EscalationChainViewSet @@ -68,7 +68,8 @@ router.register(r"tokens", PublicApiTokenView, basename="api_token") router.register(r"live_settings", LiveSettingViewSet, basename="live_settings") router.register(r"oncall_shifts", OnCallShiftView, basename="oncall_shifts") -if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: +# TODO: remove this when the hackathon app is deprecated (APNSDeviceAuthorizedViewSet is registered in mobile_app) +if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: router.register(r"device/apns", APNSDeviceAuthorizedViewSet) urlpatterns = [ diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 02d92e26..0af8fb9b 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -14,7 +14,8 @@ from apps.alerts.constants import ActionSource from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer -from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication +from apps.auth_token.auth import PluginAuthentication +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import User from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import DateRangeFilterMixin, ModelFieldFilterMixin diff --git a/engine/apps/api/views/apns_device.py b/engine/apps/api/views/apns_device.py deleted file mode 100644 index ad3b817e..00000000 --- a/engine/apps/api/views/apns_device.py +++ /dev/null @@ -1,7 +0,0 @@ -from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet - -from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication - - -class APNSDeviceAuthorizedViewSet(APNSDeviceAuthorizedViewSet): - authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index cc69514f..3a0ebfd5 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -36,7 +36,7 @@ class FeaturesAPIView(APIView): if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: enabled_features.append(FEATURE_TELEGRAM) - if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: + if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: mobile_app_settings = DynamicSetting.objects.get_or_create( name="mobile_app_settings", defaults={ diff --git a/engine/apps/api/views/team.py b/engine/apps/api/views/team.py index 0a33e71f..58bf17b3 100644 --- a/engine/apps/api/views/team.py +++ b/engine/apps/api/views/team.py @@ -2,7 +2,8 @@ from rest_framework import mixins, viewsets from rest_framework.permissions import IsAuthenticated from apps.api.serializers.team import TeamSerializer -from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication +from apps.auth_token.auth import PluginAuthentication +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Team diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index c27a713d..de0a8590 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -25,17 +25,13 @@ from apps.api.permissions import ( ) from apps.api.serializers.team import TeamSerializer from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer -from apps.auth_token.auth import ( - MobileAppAuthTokenAuthentication, - MobileAppVerificationTokenAuthentication, - PluginAuthentication, -) +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.auth_token.models.mobile_app_auth_token import MobileAppAuthToken -from apps.auth_token.models.mobile_app_verification_token import MobileAppVerificationToken from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication +from apps.mobile_app.models import MobileAppAuthToken from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode from apps.twilioapp.phone_manager import PhoneManager @@ -473,62 +469,6 @@ class UserView( raise NotFound return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=["get", "post", "delete"]) - def mobile_app_verification_token(self, request, pk): - DynamicSetting = apps.get_model("base", "DynamicSetting") - - if not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: - return Response(status=status.HTTP_404_NOT_FOUND) - - mobile_app_settings = DynamicSetting.objects.get_or_create( - name="mobile_app_settings", - defaults={ - "json_value": { - "org_ids": [], - } - }, - )[0] - if self.request.auth.organization.pk not in mobile_app_settings.json_value["org_ids"]: - return Response(status=status.HTTP_404_NOT_FOUND) - - user = self.get_object() - - if self.request.method == "GET": - try: - token = MobileAppVerificationToken.objects.get(user=user) - except MobileAppVerificationToken.DoesNotExist: - raise NotFound - - response = { - "token_id": token.id, - "user_id": token.user_id, - "organization_id": token.organization_id, - "created_at": token.created_at, - "revoked_at": token.revoked_at, - } - return Response(response, status=status.HTTP_200_OK) - - if self.request.method == "POST": - # If token already exists revoke it - try: - token = MobileAppVerificationToken.objects.get(user=user) - token.delete() - except MobileAppVerificationToken.DoesNotExist: - pass - - instance, token = MobileAppVerificationToken.create_auth_token(user, user.organization) - data = {"id": instance.pk, "token": token, "created_at": instance.created_at} - return Response(data, status=status.HTTP_201_CREATED) - - if self.request.method == "DELETE": - try: - token = MobileAppVerificationToken.objects.get(user=user) - token.delete() - except MobileAppVerificationToken.DoesNotExist: - raise NotFound - - return Response(status=status.HTTP_204_NO_CONTENT) - @action( methods=["get", "post", "delete"], detail=False, @@ -537,7 +477,7 @@ class UserView( def mobile_app_auth_token(self, request): DynamicSetting = apps.get_model("base", "DynamicSetting") - if not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: + if not settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: return Response(status=status.HTTP_404_NOT_FOUND) mobile_app_settings = DynamicSetting.objects.get_or_create( @@ -582,7 +522,7 @@ class UserView( try: token = MobileAppAuthToken.objects.get(user=self.request.user) token.delete() - except MobileAppVerificationToken.DoesNotExist: + except MobileAppAuthToken.DoesNotExist: raise NotFound return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index 5cc6399e..c1d7e553 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.conf import settings from django.http import Http404 from rest_framework import status @@ -150,7 +149,6 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): """ Returns list of options for user notification policies dropping options that requires disabled features. """ - DynamicSetting = apps.get_model("base", "DynamicSetting") choices = [] for notification_channel in NotificationChannelAPIOptions.AVAILABLE_FOR_USE: slack_integration_required = ( @@ -160,16 +158,10 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): notification_channel in NotificationChannelAPIOptions.TELEGRAM_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS ) - mobile_app_integration_required = ( - notification_channel - in NotificationChannelAPIOptions.MOBILE_APP_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS - ) if slack_integration_required and not settings.FEATURE_SLACK_INTEGRATION_ENABLED: continue if telegram_integration_required and not settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: continue - if mobile_app_integration_required and not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: - continue # extra backends may be enabled per organization built_in_backend_names = {b[0] for b in BUILT_IN_BACKENDS} @@ -178,20 +170,6 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): if extra_messaging_backend is None: continue - mobile_app_settings = DynamicSetting.objects.get_or_create( - name="mobile_app_settings", - defaults={ - "json_value": { - "org_ids": [], - } - }, - )[0] - if ( - mobile_app_integration_required - and settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED - and self.request.auth.organization.pk not in mobile_app_settings.json_value["org_ids"] - ): - continue choices.append( { "value": notification_channel, diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 68022469..47d8ece9 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -17,8 +17,6 @@ from common.constants.role import Role from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken -from .models.mobile_app_auth_token import MobileAppAuthToken -from .models.mobile_app_verification_token import MobileAppVerificationToken logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -215,39 +213,3 @@ class UserScheduleExportAuthentication(BaseAuthentication): raise exceptions.AuthenticationFailed("Export token is deactivated") return auth_token.user, auth_token - - -class MobileAppVerificationTokenAuthentication(BaseAuthentication): - model = MobileAppVerificationToken - - def authenticate(self, request) -> Tuple[User, MobileAppVerificationToken]: - auth = get_authorization_header(request).decode("utf-8") - user, auth_token = self.authenticate_credentials(auth) - return user, auth_token - - def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppVerificationToken]: - try: - auth_token = self.model.validate_token_string(token_string) - except InvalidToken: - raise exceptions.AuthenticationFailed("Invalid token") - - return auth_token.user, auth_token - - -class MobileAppAuthTokenAuthentication(BaseAuthentication): - model = MobileAppAuthToken - - def authenticate(self, request) -> Tuple[User, MobileAppAuthToken]: - auth = get_authorization_header(request).decode("utf-8") - user, auth_token = self.authenticate_credentials(auth) - if user is None: - return None - return user, auth_token - - def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppAuthToken]: - try: - auth_token = self.model.validate_token_string(token_string) - except InvalidToken: - return None, None - - return auth_token.user, auth_token diff --git a/engine/apps/auth_token/constants.py b/engine/apps/auth_token/constants.py index 676b6c88..6ea64f67 100644 --- a/engine/apps/auth_token/constants.py +++ b/engine/apps/auth_token/constants.py @@ -8,5 +8,3 @@ SLACK_AUTH_TOKEN_NAME = "slack_login_token" SCHEDULE_EXPORT_TOKEN_NAME = "token" SCHEDULE_EXPORT_TOKEN_CHARACTER_LENGTH = 32 - -MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60 diff --git a/engine/apps/auth_token/migrations/0001_squashed_initial.py b/engine/apps/auth_token/migrations/0001_squashed_initial.py index c8cb6854..7c7fe23a 100644 --- a/engine/apps/auth_token/migrations/0001_squashed_initial.py +++ b/engine/apps/auth_token/migrations/0001_squashed_initial.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.5 on 2022-05-31 14:46 +from django.utils import timezone -import apps.auth_token.models.mobile_app_verification_token import apps.auth_token.models.slack_auth_token from django.db import migrations, models @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ('digest', models.CharField(max_length=128)), ('created_at', models.DateTimeField(auto_now_add=True)), ('revoked_at', models.DateTimeField(null=True)), - ('expire_date', models.DateTimeField(default=apps.auth_token.models.mobile_app_verification_token.get_expire_date)), + ('expire_date', models.DateTimeField(default=timezone.now)), ], options={ 'abstract': False, diff --git a/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py b/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py new file mode 100644 index 00000000..08406946 --- /dev/null +++ b/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.16 on 2022-11-21 16:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth_token', '0002_squashed_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='mobileappverificationtoken', + name='organization', + ), + migrations.RemoveField( + model_name='mobileappverificationtoken', + name='user', + ), + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel( + name='MobileAppAuthToken', + ), + ], + database_operations=[ + migrations.AlterModelTable( + name='MobileAppAuthToken', + table='mobile_app_mobileappauthtoken', + ), + ], + ), + migrations.DeleteModel( + name='MobileAppVerificationToken', + ), + ] diff --git a/engine/apps/auth_token/models/mobile_app_auth_token.py b/engine/apps/auth_token/models/mobile_app_auth_token.py deleted file mode 100644 index 333ed788..00000000 --- a/engine/apps/auth_token/models/mobile_app_auth_token.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Tuple - -from django.db import models - -from apps.auth_token import constants, crypto -from apps.auth_token.models.base_auth_token import BaseAuthToken -from apps.user_management.models import Organization, User - - -class MobileAppAuthToken(BaseAuthToken): - user = models.ForeignKey( - to=User, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE - ) - organization = models.ForeignKey( - to=Organization, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE - ) - - @classmethod - def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MobileAppAuthToken", str]: - token_string = crypto.generate_token_string() - digest = crypto.hash_token_string(token_string) - - instance = cls.objects.create( - token_key=token_string[: constants.TOKEN_KEY_LENGTH], - digest=digest, - user=user, - organization=organization, - ) - return instance, token_string diff --git a/engine/apps/base/models/user_notification_policy.py b/engine/apps/base/models/user_notification_policy.py index a4c4b876..1a4a3526 100644 --- a/engine/apps/base/models/user_notification_policy.py +++ b/engine/apps/base/models/user_notification_policy.py @@ -35,8 +35,6 @@ BUILT_IN_BACKENDS = ( ("SMS", 1), ("PHONE_CALL", 2), ("TELEGRAM", 3), - ("MOBILE_PUSH_GENERAL", 5), - ("MOBILE_PUSH_CRITICAL", 6), ) @@ -201,8 +199,6 @@ class NotificationChannelOptions: UserNotificationPolicy.NotificationChannel.SMS, UserNotificationPolicy.NotificationChannel.PHONE_CALL, UserNotificationPolicy.NotificationChannel.TELEGRAM, - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL, - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL, ] + [ getattr(UserNotificationPolicy.NotificationChannel, backend_id) for backend_id, b in get_messaging_backends() @@ -213,10 +209,6 @@ class NotificationChannelOptions: SLACK_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.SLACK] TELEGRAM_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.TELEGRAM] - MOBILE_APP_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [ - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL, - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL, - ] class NotificationChannelAPIOptions(NotificationChannelOptions): @@ -225,8 +217,6 @@ class NotificationChannelAPIOptions(NotificationChannelOptions): UserNotificationPolicy.NotificationChannel.SMS: "SMS \U00002709\U0001F4F2", UserNotificationPolicy.NotificationChannel.PHONE_CALL: "Phone call \U0000260E", UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram \U0001F916", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "Mobile App", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "Mobile App Critical", } LABELS.update( { @@ -240,8 +230,6 @@ class NotificationChannelAPIOptions(NotificationChannelOptions): UserNotificationPolicy.NotificationChannel.SMS: "SMS", UserNotificationPolicy.NotificationChannel.PHONE_CALL: "\U0000260E", UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "Mobile App", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "Mobile App Critical", } SHORT_LABELS.update( { @@ -257,8 +245,6 @@ class NotificationChannelPublicAPIOptions(NotificationChannelAPIOptions): UserNotificationPolicy.NotificationChannel.SMS: "notify_by_sms", UserNotificationPolicy.NotificationChannel.PHONE_CALL: "notify_by_phone_call", UserNotificationPolicy.NotificationChannel.TELEGRAM: "notify_by_telegram", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "notify_by_mobile_app", - UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "notify_by_mobile_app_critical", } LABELS.update( { diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 4256faef..3d9e366b 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -287,10 +287,6 @@ class UserNotificationPolicyLogRecord(models.Model): result += f"called {user_verbal} by phone" elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM: result += f"sent telegram message to {user_verbal}" - elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: - result += f"sent push notifications to {user_verbal}" - elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: - result += f"sent push critical notifications to {user_verbal}" elif notification_channel is None: result += f"invited {user_verbal} but notification channel is unspecified" else: diff --git a/engine/apps/mobile_app/__init__.py b/engine/apps/mobile_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mobile_app/alert_rendering.py b/engine/apps/mobile_app/alert_rendering.py new file mode 100644 index 00000000..b33e1159 --- /dev/null +++ b/engine/apps/mobile_app/alert_rendering.py @@ -0,0 +1,13 @@ +from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater +from common.utils import str_or_backup + + +class AlertMobileAppTemplater(AlertTemplater): + def _render_for(self): + return "MOBILE_APP" + + +def get_push_notification_message(alert_group): + alert = alert_group.alerts.first() + templated_alert = AlertMobileAppTemplater(alert).render() + return str_or_backup(templated_alert.title, "Alert Group") diff --git a/engine/apps/mobile_app/auth.py b/engine/apps/mobile_app/auth.py new file mode 100644 index 00000000..72d0646a --- /dev/null +++ b/engine/apps/mobile_app/auth.py @@ -0,0 +1,45 @@ +from typing import Optional, Tuple + +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.models import User + +from .models import MobileAppAuthToken, MobileAppVerificationToken + + +class MobileAppVerificationTokenAuthentication(BaseAuthentication): + model = MobileAppVerificationToken + + def authenticate(self, request) -> Tuple[User, MobileAppVerificationToken]: + auth = get_authorization_header(request).decode("utf-8") + user, auth_token = self.authenticate_credentials(auth) + return user, auth_token + + def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppVerificationToken]: + try: + auth_token = self.model.validate_token_string(token_string) + except InvalidToken: + raise exceptions.AuthenticationFailed("Invalid token") + + return auth_token.user, auth_token + + +class MobileAppAuthTokenAuthentication(BaseAuthentication): + model = MobileAppAuthToken + + def authenticate(self, request) -> Optional[Tuple[User, MobileAppAuthToken]]: + auth = get_authorization_header(request).decode("utf-8") + user, auth_token = self.authenticate_credentials(auth) + if user is None: + return None + return user, auth_token + + def authenticate_credentials(self, token_string: str) -> Tuple[Optional[User], Optional[MobileAppAuthToken]]: + try: + auth_token = self.model.validate_token_string(token_string) + except InvalidToken: + return None, None + + return auth_token.user, auth_token diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py new file mode 100644 index 00000000..82187250 --- /dev/null +++ b/engine/apps/mobile_app/backend.py @@ -0,0 +1,56 @@ +from push_notifications.models import APNSDevice + +from apps.base.messaging import BaseMessagingBackend +from apps.mobile_app.tasks import notify_user_async + + +class MobileAppBackend(BaseMessagingBackend): + backend_id = "MOBILE_APP" + label = "Mobile app" + short_label = "Mobile app" + available_for_use = True + template_fields = ["title"] + + # TODO: add QR code generation (base64 encode?) + def generate_user_verification_code(self, user): + from apps.mobile_app.models import MobileAppVerificationToken + + # remove existing token before creating a new one + MobileAppVerificationToken.objects.filter(user=user).delete() + + _, token = MobileAppVerificationToken.create_auth_token(user, user.organization) + return token + + def unlink_user(self, user): + from apps.mobile_app.models import MobileAppAuthToken + + token = MobileAppAuthToken.objects.get(user=user) + token.delete() + + def serialize_user(self, user): + # TODO: add Android support using GCMDevice + return {"connected": APNSDevice.objects.filter(user_id=user.pk).exists()} + + def notify_user(self, user, alert_group, notification_policy, critical=False): + notify_user_async.delay( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + critical=critical, + ) + + +class MobileAppCriticalBackend(MobileAppBackend): + """ + This notification backend should not exist, criticality of the push notification should be an option passed to the + MobileAppBackend messaging backend. + TODO: add ability to pass options to messaging backends both on backend and frontend, delete this backend after that + """ + + backend_id = "MOBILE_APP_CRITICAL" + label = "Mobile app critical" + short_label = "Mobile app critical" + template_fields = [] + + def notify_user(self, user, alert_group, notification_policy, critical=True): + super().notify_user(user, alert_group, notification_policy, critical) diff --git a/engine/apps/mobile_app/migrations/0001_initial.py b/engine/apps/mobile_app/migrations/0001_initial.py new file mode 100644 index 00000000..de3faf32 --- /dev/null +++ b/engine/apps/mobile_app/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.16 on 2022-11-21 16:10 + +import apps.mobile_app.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0004_auto_20221025_0316'), + ('auth_token', '0003_auto_20221121_1610'), + ] + + operations = [ + migrations.CreateModel( + name='MobileAppVerificationToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_key', models.CharField(db_index=True, max_length=8)), + ('digest', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(null=True)), + ('expire_date', models.DateTimeField(default=apps.mobile_app.models.get_expire_date)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_verification_token_set', to='user_management.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_verification_token_set', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='MobileAppAuthToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_key', models.CharField(db_index=True, max_length=8)), + ('digest', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(null=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_auth_tokens', to='user_management.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_auth_tokens', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + ], + database_operations=[], + ) + ] diff --git a/engine/apps/mobile_app/migrations/__init__.py b/engine/apps/mobile_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/auth_token/models/mobile_app_verification_token.py b/engine/apps/mobile_app/models.py similarity index 65% rename from engine/apps/auth_token/models/mobile_app_verification_token.py rename to engine/apps/mobile_app/models.py index f67f8f3f..e42ade25 100644 --- a/engine/apps/auth_token/models/mobile_app_verification_token.py +++ b/engine/apps/mobile_app/models.py @@ -4,10 +4,11 @@ from django.db import models from django.utils import timezone from apps.auth_token import constants, crypto -from apps.auth_token.constants import MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS from apps.auth_token.models import BaseAuthToken from apps.user_management.models import Organization, User +MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60 + def get_expire_date(): return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS) @@ -46,3 +47,25 @@ class MobileAppVerificationToken(BaseAuthToken): organization=organization, ) return instance, token_string + + +class MobileAppAuthToken(BaseAuthToken): + user = models.ForeignKey( + to=User, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE + ) + organization = models.ForeignKey( + to=Organization, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE + ) + + @classmethod + def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MobileAppAuthToken", str]: + token_string = crypto.generate_token_string() + digest = crypto.hash_token_string(token_string) + + instance = cls.objects.create( + token_key=token_string[: constants.TOKEN_KEY_LENGTH], + digest=digest, + user=user, + organization=organization, + ) + return instance, token_string diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py new file mode 100644 index 00000000..e6a6a4bf --- /dev/null +++ b/engine/apps/mobile_app/tasks.py @@ -0,0 +1,95 @@ +from celery.utils.log import get_task_logger +from django.conf import settings +from push_notifications.models import APNSDevice, GCMDevice + +from apps.alerts.models import AlertGroup +from apps.mobile_app.alert_rendering import get_push_notification_message +from apps.user_management.models import User +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +MAX_RETRIES = 1 if settings.DEBUG else 10 +logger = get_task_logger(__name__) + + +@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES) +def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical): + # avoid circular import + from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord + + try: + user = User.objects.get(pk=user_pk) + except User.DoesNotExist: + logger.warning(f"User {user_pk} does not exist") + return + + try: + alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) + except AlertGroup.DoesNotExist: + logger.warning(f"Alert group {alert_group_pk} does not exist") + return + + try: + notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk) + except UserNotificationPolicy.DoesNotExist: + logger.warning(f"User notification policy {notification_policy_pk} does not exist") + return + + # APNS is for notifying iOS devices, GCM for Android + apns_devices_to_notify = APNSDevice.objects.filter(user=user) + gcm_devices_to_notify = GCMDevice.objects.filter(user=user) + + # create an error log in case user has no devices set up + if not apns_devices_to_notify.exists() and not gcm_devices_to_notify.exists(): + UserNotificationPolicyLogRecord.objects.create( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + reason="Mobile push notification error", + 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 devices set up") + return + + message = get_push_notification_message(alert_group) + thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" + + if critical: + aps = { + "alert": f"Critical page: {message}", + "interruption-level": "critical", + "sound": "ambulance.aiff", + } + else: + aps = { + "alert": message, + "sound": "bingbong.aiff", + } + + apns_devices_to_notify.send_message( + message, + thread_id=thread_id, + category="USER_NEW_INCIDENT", # TODO: rename to USER_NEW_ALERT_GROUP + extra={ + "orgId": alert_group.channel.organization.public_primary_key, + "orgName": alert_group.channel.organization.stack_slug, + "alertGroupId": alert_group.public_primary_key, + "incidentId": alert_group.public_primary_key, # TODO: remove after hackathon app is deprecated + "status": alert_group.status, + "aps": aps, + }, + ) + + gcm_devices_to_notify.send_message( + message, + thread_id=thread_id, + category="USER_NEW_INCIDENT", # TODO: rename to USER_NEW_ALERT_GROUP + extra={ + "orgId": alert_group.channel.organization.public_primary_key, + "orgName": alert_group.channel.organization.stack_slug, + "alertGroupId": alert_group.public_primary_key, + "status": alert_group.status, + "aps": aps, + }, + ) diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py new file mode 100644 index 00000000..059503fb --- /dev/null +++ b/engine/apps/mobile_app/urls.py @@ -0,0 +1,9 @@ +from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet +from common.api_helpers.optional_slash_router import OptionalSlashRouter + +router = OptionalSlashRouter() + +router.register("apns", APNSDeviceAuthorizedViewSet) +router.register("gcm", GCMDeviceAuthorizedViewSet) + +urlpatterns = router.urls diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py new file mode 100644 index 00000000..9755b74d --- /dev/null +++ b/engine/apps/mobile_app/views.py @@ -0,0 +1,12 @@ +from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet as BaseAPNSDeviceAuthorizedViewSet +from push_notifications.api.rest_framework import GCMDeviceAuthorizedViewSet as BaseGCMDeviceAuthorizedViewSet + +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication + + +class APNSDeviceAuthorizedViewSet(BaseAPNSDeviceAuthorizedViewSet): + authentication_classes = (MobileAppAuthTokenAuthentication,) + + +class GCMDeviceAuthorizedViewSet(BaseGCMDeviceAuthorizedViewSet): + authentication_classes = (MobileAppAuthTokenAuthentication,) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 7a86b57f..ab6934d8 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -51,6 +51,12 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] +if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: + urlpatterns += [ + path("mobile_app/", include("apps.mobile_app.urls")), + ] + + if settings.OSS_INSTALLATION: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), diff --git a/engine/settings/base.py b/engine/settings/base.py index b50b19b6..49036059 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -52,6 +52,7 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED", FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=True) FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=True) FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=True) +FEATURE_MOBILE_APP_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MOBILE_APP_INTEGRATION_ENABLED", default=False) FEATURE_WEB_SCHEDULES_ENABLED = getenv_boolean("FEATURE_WEB_SCHEDULES_ENABLED", default=False) FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) @@ -202,6 +203,7 @@ INSTALLED_APPS = [ "apps.slack", "apps.telegram", "apps.twilioapp", + "apps.mobile_app", "apps.api", "apps.api_for_grafana_incident", "apps.base", @@ -540,9 +542,16 @@ GRAFANA_COM_ADMIN_API_TOKEN = os.environ.get("GRAFANA_COM_ADMIN_API_TOKEN", None GRAFANA_API_KEY_NAME = "Grafana OnCall" -MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED = getenv_boolean("MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED", default=False) +EXTRA_MESSAGING_BACKENDS = [] +if FEATURE_MOBILE_APP_INTEGRATION_ENABLED: + EXTRA_MESSAGING_BACKENDS += [ + ("apps.mobile_app.backend.MobileAppBackend", 5), + ("apps.mobile_app.backend.MobileAppCriticalBackend", 6), + ] PUSH_NOTIFICATIONS_SETTINGS = { + "FCM_API_KEY": os.environ.get("FCM_API_KEY", None), + "GCM_API_KEY": os.environ.get("GCM_API_KEY", None), "APNS_AUTH_KEY_PATH": os.environ.get("APNS_AUTH_KEY_PATH", None), "APNS_TOPIC": os.environ.get("APNS_TOPIC", None), "APNS_AUTH_KEY_ID": os.environ.get("APNS_AUTH_KEY_ID", None), @@ -569,8 +578,6 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = getenv_integer("DATA_UPLOAD_MAX_MEMORY_SIZE", 1_04 # Log inbound/outbound calls as slow=1 if they exceed threshold SLOW_THRESHOLD_SECONDS = 2.0 -EXTRA_MESSAGING_BACKENDS = [] - # Email messaging backend EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = os.getenv("EMAIL_HOST") @@ -581,7 +588,7 @@ EMAIL_USE_TLS = getenv_boolean("EMAIL_USE_TLS", True) EMAIL_FROM_ADDRESS = os.getenv("EMAIL_FROM_ADDRESS") if FEATURE_EMAIL_INTEGRATION_ENABLED: - EXTRA_MESSAGING_BACKENDS = [("apps.email.backend.EmailBackend", 8)] + EXTRA_MESSAGING_BACKENDS += [("apps.email.backend.EmailBackend", 8)] INSTALLED_ONCALL_INTEGRATIONS = [ "config_integrations.alertmanager", diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 879bf3e9..ba5e5a33 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -105,6 +105,7 @@ CELERY_TASK_ROUTES = { "apps.integrations.tasks.create_alert": {"queue": "critical"}, "apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"}, "apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"}, + "apps.mobile_app.tasks.notify_user_async": {"queue": "critical"}, "apps.schedules.tasks.drop_cached_ical.drop_cached_ical_for_custom_events_for_organization": {"queue": "critical"}, "apps.schedules.tasks.drop_cached_ical.drop_cached_ical_task": {"queue": "critical"}, # LONG diff --git a/grafana-plugin/src/PluginPage.tsx b/grafana-plugin/src/PluginPage.tsx index b8bce7a8..43e0d91f 100644 --- a/grafana-plugin/src/PluginPage.tsx +++ b/grafana-plugin/src/PluginPage.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime'; import Header from 'navbar/Header/Header'; +import { pages } from 'pages'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { useStore } from 'state/useStore'; import { useQueryParams } from 'utils/hooks'; @@ -18,6 +19,7 @@ function RealPlugin(props: PluginPageProps): React.ReactNode { return (
+

{pages[page].text}

{props.children} ); diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 782ea07a..71c38142 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { getLocationSrv } from '@grafana/runtime'; import { Label, Button, HorizontalGroup, VerticalGroup, Select, LoadingPlaceholder } from '@grafana/ui'; import { capitalCase } from 'change-case'; import cn from 'classnames/bind'; @@ -19,6 +18,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { Alert } from 'models/alertgroup/alertgroup.types'; import { makeRequest } from 'network'; import { UserAction } from 'state/userAction'; +import LocationHelper from 'utils/LocationHelper'; import styles from './AlertTemplatesForm.module.css'; @@ -162,9 +162,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { ) : null} ); - const handleGoToTemplateSettingsCllick = () => { - getLocationSrv().update({ partial: true, query: { tab: 'Autoresolve' } }); - }; + const handleGoToTemplateSettingsCllick = () => LocationHelper.update({ tab: 'Autoresolve' }, 'partial'); return (
diff --git a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx index 224698dd..7644563c 100644 --- a/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx @@ -36,7 +36,7 @@ export default function PageErrorHandlingWrapper({ objectName?: string; pageName: string; itemNotFoundMessage?: string; - children: React.ReactNode; + children: () => React.ReactNode; }): JSX.Element { useEffect(() => { if (!errorData) { @@ -51,7 +51,7 @@ export default function PageErrorHandlingWrapper({ const store = useStore(); if (!errorData || !errorData.isWrongTeamError) { - return <>{children}; + return <>{children()}; } const currentTeamId = store.userStore.currentUser?.current_team; diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx index 9f18e251..c98c58f7 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx @@ -1,7 +1,6 @@ import plugin from '../../../package.json'; // eslint-disable-line import React, { FC, useEffect, useState, useCallback } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -12,6 +11,7 @@ import { getIfChatOpsConnected } from 'containers/DefaultPageLayout/helper'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; +import LocationHelper from 'utils/LocationHelper'; import { GRAFANA_LICENSE_OSS } from 'utils/consts'; import { useForceUpdate } from 'utils/hooks'; import { getItem, setItem } from 'utils/localStorage'; @@ -46,7 +46,7 @@ const DefaultPageLayout: FC = observer((props) => { if (query.slack_error) { setShowSlackInstallAlert(query.slack_error); - getLocationSrv().update({ partial: true, query: { slack_error: undefined }, replace: true }); + LocationHelper.update({ slack_error: undefined }, 'replace'); } }, []); diff --git a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx index 2d35e3d8..f6652400 100644 --- a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx +++ b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Drawer, Tab, TabContent, TabsBar, Button, VerticalGroup, Input } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -15,6 +14,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { Alert } from 'models/alertgroup/alertgroup.types'; import { useStore } from 'state/useStore'; import { openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import { IntegrationSettingsTab } from './IntegrationSettings.types'; import Autoresolve from './parts/Autoresolve'; @@ -46,7 +46,7 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => { const getTabClickHandler = useCallback((tab: IntegrationSettingsTab) => { return () => { setActiveTab(tab); - getLocationSrv().update({ partial: true, query: { tab: tab } }); + LocationHelper.update({ tab }, 'partial'); }; }, []); @@ -56,7 +56,7 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => { useEffect(() => { setActiveTab(startTab || IntegrationSettingsTab.Templates); - getLocationSrv().update({ partial: true, query: { tab: startTab || IntegrationSettingsTab.Templates } }); + LocationHelper.update({ tab: startTab || IntegrationSettingsTab.Templates }, 'partial'); }, [startTab]); const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel); diff --git a/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx b/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx index 3fdf07bf..420c64a1 100644 --- a/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx +++ b/grafana-plugin/src/containers/IntegrationSettings/parts/Autoresolve.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState, useEffect } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert, Button, Icon, Label, Modal, Select } from '@grafana/ui'; import cn from 'classnames/bind'; import { get } from 'lodash-es'; @@ -15,6 +14,7 @@ import { Team } from 'models/team/team.types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; import { openErrorNotification, openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import styles from 'containers/IntegrationSettings/parts/Autoresolve.module.css'; @@ -114,7 +114,7 @@ const Autoresolve = ({ alertReceiveChannelId, onSwitchToTemplate, alertGroupId } }; const handleGoToTemplateSettingsCllick = () => { - getLocationSrv().update({ partial: true, query: { tab: 'Templates' } }); + LocationHelper.update({ tab: 'Templates' }, 'partial'); onSwitchToTemplate('resolve_condition_template'); }; diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index d629b90f..5413c270 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -34,26 +34,19 @@ const MobileAppVerification = observer((props: MobileAppVerificationProps) => { const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState(false); const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState(true); - useEffect(() => { - userStore - .getMobileAppVerificationToken(userPk) - .then((_res) => { - setIsMobileAppVerificationTokenExisting(true); - setMobileAppVerificationTokenLoading(false); - }) - .catch((_res) => { - setIsMobileAppVerificationTokenExisting(false); - setMobileAppVerificationTokenLoading(false); - }); - }, []); - const handleCreateMobileAppVerificationToken = async () => { setIsMobileAppVerificationTokenExisting(true); await userStore - .createMobileAppVerificationToken(userPk) - .then((res) => setShowMobileAppVerificationToken(res?.token)); + .sendBackendConfirmationCode(userPk, 'MOBILE_APP') + .then((res) => setShowMobileAppVerificationToken(res)); }; + useEffect(() => { + handleCreateMobileAppVerificationToken().then(() => { + setMobileAppVerificationTokenLoading(false); + }); + }, []); + return (
{MobileAppVerificationTokenLoading ? ( diff --git a/grafana-plugin/src/img/grafanaGlobalStyles.css b/grafana-plugin/src/img/grafanaGlobalStyles.css index 29ac020c..9e29d7ba 100644 --- a/grafana-plugin/src/img/grafanaGlobalStyles.css +++ b/grafana-plugin/src/img/grafanaGlobalStyles.css @@ -9,7 +9,7 @@ padding-bottom: 36px; } -.scrollbar-view [class*='-page-header'] { +.scrollbar-view h1:first-child { margin-bottom: 0 !important; } @@ -21,6 +21,7 @@ max-width: unset !important; flex-grow: unset !important; flex-basis: unset !important; + overflow-x: auto; } .page-scrollbar-content > div:first-child { @@ -29,6 +30,7 @@ .page-header__title { padding-top: 0 !important; + font-size: 2rem !important; margin-right: 8px; } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 3db546fc..8d7ea002 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -378,16 +378,4 @@ export class UserStore extends BaseStore { method: 'DELETE', }); } - - async getMobileAppVerificationToken(userPk: User['pk']) { - return await makeRequest(`/users/${userPk}/mobile_app_verification_token/`, { - method: 'GET', - }); - } - - async createMobileAppVerificationToken(userPk: User['pk']) { - return await makeRequest(`/users/${userPk}/mobile_app_verification_token/`, { - method: 'POST', - }); - } } diff --git a/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss b/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss new file mode 100644 index 00000000..c3b2ca3c --- /dev/null +++ b/grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss @@ -0,0 +1,3 @@ +.root { + min-width: 1500px; +} diff --git a/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx b/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx index d96ba975..c6564d3c 100644 --- a/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx +++ b/grafana-plugin/src/navbar/LegacyNavTabsBar.tsx @@ -2,10 +2,15 @@ import React from 'react'; import { IconName } from '@grafana/data'; import { Tab, TabsBar } from '@grafana/ui'; +import cn from 'classnames/bind'; import { pages } from 'pages'; import { useStore } from 'state/useStore'; +import styles from './LegacyNavTabsBar.module.scss'; + +const cx = cn.bind(styles); + export default function LegacyNavTabsBar({ currentPage }: { currentPage: string }): JSX.Element { const store = useStore(); @@ -14,7 +19,7 @@ export default function LegacyNavTabsBar({ currentPage }: { currentPage: string .filter((page) => (page.hideFromTabsFn ? !page.hideFromTabsFn(store) : !page.hideFromTabs)); return ( - + {navigationPages.map((page, index) => ( { - getLocationSrv().update({ partial: true, query: { id: escalationChain } }); + LocationHelper.update({ id: escalationChain }, 'partial'); if (escalationChain) { escalationChainStore.updateEscalationChainDetails(escalationChain); } @@ -143,79 +142,81 @@ class EscalationChainsPage extends React.Component - <> -
-
- -
- {!searchResult || searchResult.length ? ( -
-
- - - -
- {searchResult ? ( - - {(item) => } - - ) : ( - - )} -
-
-
{this.renderEscalation()}
+ {() => ( + <> +
+
+
- ) : ( - - No escalations found, check your filtering and current team. - + {!searchResult || searchResult.length ? ( +
+
+ - - } +
+ {searchResult ? ( + + {(item) => } + + ) : ( + + )} +
+
+
{this.renderEscalation()}
+
+ ) : ( + + No escalations found, check your filtering and current team. + + + + + } + /> + )} +
+ {showCreateEscalationChainModal && ( + { + this.setState({ + showCreateEscalationChainModal: false, + escalationChainIdToCopy: undefined, + }); + }} + onUpdate={this.handleEscalationChainCreate} /> )} -
- {showCreateEscalationChainModal && ( - { - this.setState({ - showCreateEscalationChainModal: false, - escalationChainIdToCopy: undefined, - }); - }} - onUpdate={this.handleEscalationChainCreate} - /> - )} - + + )} ); diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 2125e374..c4f48065 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -1,6 +1,5 @@ import React, { useState, SyntheticEvent } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, @@ -22,7 +21,6 @@ import moment from 'moment-timezone'; import CopyToClipboard from 'react-copy-to-clipboard'; import Emoji from 'react-emoji-render'; import reactStringReplace from 'react-string-replace'; -import { AppRootProps } from 'types'; import Collapse from 'components/Collapse/Collapse'; import Block from 'components/GBlock/Block'; @@ -49,12 +47,12 @@ import { } from 'models/alertgroup/alertgroup.types'; import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import { openNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import sanitize from 'utils/sanitize'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers'; @@ -63,7 +61,7 @@ import styles from './Incident.module.css'; const cx = cn.bind(styles); -interface IncidentPageProps extends WithStoreProps, AppRootProps {} +interface IncidentPageProps extends WithStoreProps, PageProps {} interface IncidentPageState extends PageBaseState { showIntegrationSettings?: boolean; @@ -97,8 +95,10 @@ class IncidentPage extends React.Component update = () => { this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false - const { store } = this.props; - const { id } = getQueryParams(); + const { + store, + query: { id }, + } = this.props; store.alertGroupStore .getAlert(id) @@ -106,8 +106,10 @@ class IncidentPage extends React.Component }; render() { - const { store } = this.props; - const { id, cursor, start, perpage } = getQueryParams(); + const { + store, + query: { id, cursor, start, perpage }, + } = this.props; const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state; const { isNotFoundError, isWrongTeamError } = errorData; @@ -127,74 +129,78 @@ class IncidentPage extends React.Component return ( -
- {errorData.isNotFoundError ? ( -
- - 404 - Incident not found - - - - -
- ) : ( - <> - {this.renderHeader()} -
-
- - - -
-
{this.renderTimeline()}
+ {() => ( +
+ {errorData.isNotFoundError ? ( +
+ + 404 + Incident not found + + + +
- {showIntegrationSettings && ( - { - alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id); - }} - onUpdateTemplates={() => { - store.alertGroupStore.getAlert(id); - }} - startTab={IntegrationSettingsTab.Templates} - id={incident.alert_receive_channel.id} - onHide={() => - this.setState({ - showIntegrationSettings: undefined, - }) - } - /> - )} - {showAttachIncidentForm && ( - { - this.setState({ - showAttachIncidentForm: false, - }); - }} - onUpdate={this.update} - /> - )} - - )} -
+ ) : ( + <> + {this.renderHeader()} +
+
+ + + +
+
{this.renderTimeline()}
+
+ {showIntegrationSettings && ( + { + alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id); + }} + onUpdateTemplates={() => { + store.alertGroupStore.getAlert(id); + }} + startTab={IntegrationSettingsTab.Templates} + id={incident.alert_receive_channel.id} + onHide={() => + this.setState({ + showIntegrationSettings: undefined, + }) + } + /> + )} + {showAttachIncidentForm && ( + { + this.setState({ + showAttachIncidentForm: false, + }); + }} + onUpdate={this.update} + /> + )} + + )} +
+ )} ); } renderHeader = () => { - const { store } = this.props; + const { + store, + query: { id, cursor, start, perpage }, + } = this.props; - const { id, cursor, start, perpage } = getQueryParams(); const { alerts } = store.alertGroupStore; const incident = alerts.get(id); @@ -311,9 +317,11 @@ class IncidentPage extends React.Component }; renderTimeline = () => { - const { store } = this.props; + const { + store, + query: { id }, + } = this.props; - const { id } = getQueryParams(); const incident = store.alertGroupStore.alerts.get(id); if (!incident.render_after_resolve_report_json) { @@ -401,9 +409,11 @@ class IncidentPage extends React.Component }; handleCreateResolutionNote = () => { - const { store } = this.props; + const { + store, + query: { id }, + } = this.props; - const { id } = getQueryParams(); const { resolutionNoteText } = this.state; store.resolutionNotesStore .createResolutionNote(id, resolutionNoteText) @@ -419,9 +429,7 @@ class IncidentPage extends React.Component case 'author': return ( { - getLocationSrv().update({ query: { page: 'users', id: entity?.author?.pk } }); - }} + onClick={() => LocationHelper.update({ id: entity?.author?.pk, page: 'users' }, 'replace')} style={{ textDecoration: 'underline', cursor: 'pointer' }} > {entity.author?.username} diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 3ef595a6..198c2da4 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -1,6 +1,5 @@ import React, { ReactElement, SyntheticEvent } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; @@ -8,12 +7,10 @@ import { get } from 'lodash-es'; import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import Emoji from 'react-emoji-render'; -import { AppRootProps } from 'types'; import CursorPagination from 'components/CursorPagination/CursorPagination'; import GTable from 'components/GTable/GTable'; import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; -import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import Tutorial from 'components/Tutorial/Tutorial'; @@ -25,11 +22,11 @@ import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertg import { User } from 'models/user/user.types'; import { pages } from 'pages'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; import { move } from 'state/helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import SilenceDropdown from './parts/SilenceDropdown'; @@ -54,7 +51,7 @@ function withSkeleton(fn: (alert: AlertType) => ReactElement | ReactElement[]) { return WithSkeleton; } -interface IncidentsPageProps extends WithStoreProps, AppRootProps {} +interface IncidentsPageProps extends WithStoreProps, PageProps {} interface IncidentsPageState { selectedIncidentIds: Array; @@ -71,8 +68,10 @@ class Incidents extends React.Component constructor(props: IncidentsPageProps) { super(props); - const { store } = props; - const { cursor: cursorQuery, start: startQuery, perpage: perpageQuery } = getQueryParams(); + const { + store, + query: { cursor: cursorQuery, start: startQuery, perpage: perpageQuery }, + } = props; const cursor = cursorQuery || undefined; const start = !isNaN(startQuery) ? Number(startQuery) : 1; @@ -103,12 +102,10 @@ class Incidents extends React.Component render() { return ( - -
- {this.renderIncidentFilters()} - {this.renderTable()} -
-
+
+ {this.renderIncidentFilters()} + {this.renderTable()} +
); } @@ -148,7 +145,7 @@ class Incidents extends React.Component fetchIncidentData = (filters: IncidentsFiltersType, isOnMount: boolean) => { const { store } = this.props; store.alertGroupStore.updateIncidentFilters(filters, isOnMount); // this line fetches incidents - getLocationSrv().update({ query: { page: 'incidents', ...store.alertGroupStore.incidentFilters } }); + LocationHelper.update({ page: 'incidents', ...store.alertGroupStore.incidentFilters }, 'partial'); }; onChangeCursor = (cursor: string, direction: 'prev' | 'next') => { @@ -577,7 +574,9 @@ class Incidents extends React.Component } setPollingInterval(filters: IncidentsFiltersType = this.state.filters, isOnMount = false) { - this.pollingIntervalId = setInterval(() => this.fetchIncidentData(filters, isOnMount), POLLING_NUM_SECONDS * 1000); + this.pollingIntervalId = setInterval(() => { + this.fetchIncidentData(filters, isOnMount); + }, POLLING_NUM_SECONDS * 1000); } } diff --git a/grafana-plugin/src/pages/index.tsx b/grafana-plugin/src/pages/index.tsx index b3fa2499..a837581a 100644 --- a/grafana-plugin/src/pages/index.tsx +++ b/grafana-plugin/src/pages/index.tsx @@ -101,7 +101,7 @@ export const pages: { [id: string]: PageDefinition } = [ { icon: 'cog', id: 'settings', - text: 'Organization Settings', + text: 'Settings', hideFromBreadcrumbs: true, path: getPath('settings'), }, diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index e3a759b4..a5fb20c4 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; -import { AppRootProps } from 'types'; import GList from 'components/GList/GList'; import IntegrationsFilters, { Filters } from 'components/IntegrationsFilters/IntegrationsFilters'; @@ -27,9 +25,10 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { AlertReceiveChannel } from 'models/alert_receive_channel'; import { AlertReceiveChannelOption } from 'models/alert_receive_channel/alert_receive_channel.types'; import { pages } from 'pages'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './Integrations.module.css'; @@ -42,7 +41,7 @@ interface IntegrationsState extends PageBaseState { integrationSettingsTab?: IntegrationSettingsTab; } -interface IntegrationsProps extends WithStoreProps, AppRootProps {} +interface IntegrationsProps extends WithStoreProps, PageProps {} @observer class Integrations extends React.Component { @@ -62,7 +61,7 @@ class Integrations extends React.Component setSelectedAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => { const { store } = this.props; store.selectedAlertReceiveChannel = alertReceiveChannelId; - getLocationSrv().update({ partial: true, query: { id: alertReceiveChannelId } }); + LocationHelper.update({ id: alertReceiveChannelId }, 'partial'); }; parseQueryParams = async () => { @@ -139,110 +138,112 @@ class Integrations extends React.Component pageName="integrations" itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`} > - <> -
-
- -
- {searchResult?.length ? ( -
-
- - - -
- - {(item) => ( - { - this.setState({ - alertReceiveChannelToShowSettings: item.id, - integrationSettingsTab: IntegrationSettingsTab.Heartbeat, - }); - }} - /> - )} - -
-
-
- { - this.setState({ - alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, - integrationSettingsTab, - }); - }} - /> -
+ {() => ( + <> +
+
+
- ) : searchResult ? ( - - No integrations found. Review your filter and team settings. + {searchResult?.length ? ( +
+
- - } +
+ + {(item) => ( + { + this.setState({ + alertReceiveChannelToShowSettings: item.id, + integrationSettingsTab: IntegrationSettingsTab.Heartbeat, + }); + }} + /> + )} + +
+
+
+ { + this.setState({ + alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, + integrationSettingsTab, + }); + }} + /> +
+
+ ) : searchResult ? ( + + No integrations found. Review your filter and team settings. + + + + + } + /> + ) : ( + + )} +
+ {alertReceiveChannelToShowSettings && ( + { + alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); + }} + startTab={integrationSettingsTab} + id={alertReceiveChannelToShowSettings} + onHide={() => { + this.setState({ + alertReceiveChannelToShowSettings: undefined, + integrationSettingsTab: undefined, + }); + LocationHelper.update({ tab: undefined }, 'partial'); + }} /> - ) : ( - )} -
- {alertReceiveChannelToShowSettings && ( - { - alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); - }} - startTab={integrationSettingsTab} - id={alertReceiveChannelToShowSettings} - onHide={() => { - this.setState({ - alertReceiveChannelToShowSettings: undefined, - integrationSettingsTab: undefined, - }); - getLocationSrv().update({ partial: true, query: { tab: undefined } }); - }} - /> - )} - {showCreateIntegrationModal && ( - { - this.setState({ showCreateIntegrationModal: false }); - }} - onCreate={this.handleCreateNewAlertReceiveChannel} - /> - )} - + {showCreateIntegrationModal && ( + { + this.setState({ showCreateIntegrationModal: false }); + }} + onCreate={this.handleCreateNewAlertReceiveChannel} + /> + )} + + )} ); diff --git a/grafana-plugin/src/pages/maintenance/Maintenance.tsx b/grafana-plugin/src/pages/maintenance/Maintenance.tsx index 4d329475..9ec72ea3 100644 --- a/grafana-plugin/src/pages/maintenance/Maintenance.tsx +++ b/grafana-plugin/src/pages/maintenance/Maintenance.tsx @@ -7,7 +7,6 @@ import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; import Emoji from 'react-emoji-render'; -import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import Text from 'components/Text/Text'; @@ -18,7 +17,7 @@ import { getAlertReceiveChannelDisplayName } from 'models/alert_receive_channel/ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Maintenance, MaintenanceMode, MaintenanceType } from 'models/maintenance/maintenance.types'; import { pages } from 'pages'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; @@ -26,7 +25,7 @@ import styles from './Maintenance.module.css'; const cx = cn.bind(styles); -interface MaintenancePageProps extends AppRootProps, WithStoreProps {} +interface MaintenancePageProps extends PageProps, WithStoreProps {} interface MaintenancePageState { maintenanceData?: { diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx new file mode 100644 index 00000000..463b3256 --- /dev/null +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.test.tsx @@ -0,0 +1,86 @@ +import 'jest/matchMedia.ts'; +import React from 'react'; + +import { describe, expect, test } from '@jest/globals'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import outgoingWebhooksStub from 'jest/outgoingWebhooksStub'; + +import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; +import { OutgoingWebhooks } from 'pages/outgoing_webhooks/OutgoingWebhooks'; + +const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[]; +const outgoingWebhookStore = () => ({ + loadItem: () => Promise.resolve(outgoingWebhooks[0]), + updateItems: () => Promise.resolve(), + getSearchResult: () => outgoingWebhooks, + items: outgoingWebhooks.reduce((prev, current) => { + prev[current.id] = current; + return prev; + }, {}), +}); + +jest.mock('plugin/GrafanaPluginRootPage.helpers', () => ({ + isTopNavbar: () => false, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + topNav: false, + }, + }, +})); + +jest.mock('state/useStore', () => ({ + useStore: () => ({ + outgoingWebhookStore: outgoingWebhookStore(), + isUserActionAllowed: jest.fn().mockReturnValue(true), + }), +})); + +jest.mock('@grafana/runtime', () => ({ + getLocationSrv: jest.fn(), +})); + +describe('OutgoingWebhooks', () => { + const storeMock = { + isUserActionAllowed: jest.fn().mockReturnValue(true), + outgoingWebhookStore: outgoingWebhookStore(), + }; + + beforeAll(() => { + console.warn = () => {}; + console.error = () => {}; + }); + + test('It renders all retrieved webhooks', async () => { + render(); + + await waitFor(() => { + const gTable = screen.queryByTestId('test__gTable'); + const rows = gTable.querySelectorAll('tbody tr'); + + expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined] + expect(rows.length).toBe(outgoingWebhooks.length); + }); + }); + + test('It opens Edit View if [id] is supplied', async () => { + const id = outgoingWebhooks[0].id; + render(); + + expect(() => queryEditForm()).toThrow(); // before updates kick in + await waitFor(() => { + expect(queryEditForm()).toBeDefined(); // edit shows for [id=?] + }); + }); + + function getProps(id: OutgoingWebhook['id'] = undefined): any { + return { store: storeMock, query: { id } }; + } + + function queryEditForm(): HTMLElement { + return screen.getByTestId('test__outgoingWebhookEditForm'); + } +}); diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 797fc6d6..1ff3f4d7 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; -import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; @@ -22,16 +20,16 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { ActionDTO } from 'models/action'; import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './OutgoingWebhooks.module.css'; const cx = cn.bind(styles); -interface OutgoingWebhooksProps extends WithStoreProps, AppRootProps {} +interface OutgoingWebhooksProps extends WithStoreProps, PageProps {} interface OutgoingWebhooksState extends PageBaseState { outgoingWebhookIdToEdit?: OutgoingWebhook['id'] | 'new'; @@ -43,14 +41,12 @@ class OutgoingWebhooks extends React.Component - <> -
- ( -
- - Outgoing Webhooks - -
- - - - - + {() => ( + <> +
+ ( +
+ + Outgoing Webhooks + +
+ + + + + +
-
- )} - rowKey="id" - columns={columns} - data={webhooks} - /> -
- {outgoingWebhookIdToEdit && ( - - )} - + )} + rowKey="id" + columns={columns} + data={webhooks} + /> +
+ {outgoingWebhookIdToEdit && ( + + )} + + )} ); @@ -194,14 +192,14 @@ class OutgoingWebhooks extends React.Component { this.setState({ outgoingWebhookIdToEdit: id }); - getLocationSrv().update({ partial: true, query: { id } }); + LocationHelper.update({ id }, 'partial'); }; }; handleOutgoingWebhookFormHide = () => { this.setState({ outgoingWebhookIdToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); + LocationHelper.update({ id: undefined }, 'partial'); }; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 7f99d9fe..87e02706 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import { AppRootProps } from 'types'; import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PluginLink from 'components/PluginLink/PluginLink'; @@ -23,10 +21,10 @@ import UsersTimezones from 'containers/UsersTimezones/UsersTimezones'; import { Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import { getStartOfWeek } from './Schedule.helpers'; @@ -34,7 +32,7 @@ import styles from './Schedule.module.css'; const cx = cn.bind(styles); -interface SchedulePageProps extends AppRootProps, WithStoreProps {} +interface SchedulePageProps extends PageProps, WithStoreProps {} interface SchedulePageState { startMoment: dayjs.Dayjs; @@ -66,8 +64,10 @@ class SchedulePage extends React.Component } async componentDidMount() { - const { store } = this.props; - const { id } = getQueryParams(); + const { + store, + query: { id }, + } = this.props; store.userStore.updateItems(); @@ -86,8 +86,10 @@ class SchedulePage extends React.Component } render() { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; const { startMoment, @@ -112,139 +114,150 @@ class SchedulePage extends React.Component return ( -
- -
- - - - - - - {schedule?.name} - - {schedule && } - - - {users && ( + {() => ( + <> +
+ +
+ - Current timezone: - + + + + + {schedule?.name} + + {schedule && } - )} - - - - {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && ( - + + {users && ( + + Current timezone: + + )} + + + + {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && ( + + )} + + { + this.setState({ showEditForm: true }); + }} + /> + + + + - { - this.setState({ showEditForm: true }); - }} - /> - - - - - -
-
- -
+
+
+ +
-
-
- - - - - - +
+
+ + + + + + + + + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} + + - - {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} - - - -
- - - +
+ + + +
+
- -
- {showEditForm && ( - { - this.setState({ showEditForm: false }); - }} - /> - )} - {showScheduleICalSettings && ( - this.setState({ showScheduleICalSettings: false })} - > - - + {showEditForm && ( + { + this.setState({ showEditForm: false }); + }} + /> + )} + {showScheduleICalSettings && ( + this.setState({ showScheduleICalSettings: false })} + > + + + )} + )} @@ -293,8 +306,10 @@ class SchedulePage extends React.Component }; updateEvents = () => { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; const { startMoment } = this.state; @@ -418,12 +433,12 @@ class SchedulePage extends React.Component }; handleDelete = () => { - const { store } = this.props; - const { id: scheduleId } = getQueryParams(); + const { + store, + query: { id: scheduleId }, + } = this.props; - store.scheduleStore.delete(scheduleId).then(() => { - getLocationSrv().update({ query: { page: 'schedules' } }); - }); + store.scheduleStore.delete(scheduleId).then(() => LocationHelper.update({ page: 'schedules' }, 'replace')); }; } diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 7b7cdbd1..18d6ca52 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; @@ -31,6 +30,7 @@ import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import styles from './Schedules.module.css'; @@ -210,7 +210,7 @@ class SchedulesPage extends React.Component { if (data.type === ScheduleType.API) { - getLocationSrv().update({ query: { page: 'schedule', id: data.id } }); + LocationHelper.update({ page: 'schedule', id: data.id }, 'replace'); } }; @@ -259,9 +259,7 @@ class SchedulesPage extends React.Component { - return () => { - getLocationSrv().update({ query: { page: 'schedule', id: scheduleId } }); - }; + return () => LocationHelper.update({ page: 'schedule', id: scheduleId }, 'replace'); }; renderType = (value: number) => { diff --git a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx index 2675f113..17ebd610 100644 --- a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Field, Input, Button, HorizontalGroup, Icon, VerticalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -15,6 +14,7 @@ import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; import { openErrorNotification } from 'utils'; +import LocationHelper from 'utils/LocationHelper'; import styles from './CloudPage.module.css'; @@ -116,7 +116,7 @@ const CloudPage = observer((_props: CloudPageProps) => { variant="secondary" size="sm" className={cx('table-button')} - onClick={() => getLocationSrv().update({ query: { page: 'users', p: page, id: user.id } })} + onClick={() => LocationHelper.update({ page: 'users', p: page, id: user.id }, 'replace')} > Configure notifications diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index 623b379b..6faa3b55 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -1,13 +1,11 @@ import React from 'react'; -import { getLocationSrv } from '@grafana/runtime'; import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; -import { AppRootProps } from 'types'; import Avatar from 'components/Avatar/Avatar'; import GTable from 'components/GTable/GTable'; @@ -24,10 +22,10 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { getRole } from 'models/user/user.helpers'; import { User as UserType, UserRole } from 'models/user/user.types'; import { pages } from 'pages'; -import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; -import { WithStoreProps } from 'state/types'; +import { PageProps, WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import LocationHelper from 'utils/LocationHelper'; import { getRealFilters, getUserRowClassNameFn } from './Users.helpers'; @@ -35,7 +33,7 @@ import styles from './Users.module.css'; const cx = cn.bind(styles); -interface UsersProps extends WithStoreProps, AppRootProps {} +interface UsersProps extends WithStoreProps, PageProps {} const ITEMS_PER_PAGE = 100; @@ -65,10 +63,10 @@ class Users extends React.Component { initialUsersLoaded = false; - private userId: string; - async componentDidMount() { - const { p } = getQueryParams(); + const { + query: { p }, + } = this.props; this.setState({ page: p ? Number(p) : 1 }, this.updateUsers); this.parseParams(); @@ -83,11 +81,11 @@ class Users extends React.Component { return; } - getLocationSrv().update({ query: { p: page }, partial: true }); + LocationHelper.update({ p: page }, 'partial'); return await userStore.updateItems(getRealFilters(usersFilters), page); }; - componentDidUpdate() { + componentDidUpdate(prevProps: UsersProps) { const { store } = this.props; if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) { @@ -95,7 +93,7 @@ class Users extends React.Component { this.initialUsersLoaded = true; } - if (this.userId !== getQueryParams()['id']) { + if (prevProps.query.id !== this.props.query.id) { this.parseParams(); } } @@ -103,10 +101,10 @@ class Users extends React.Component { parseParams = async () => { this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse - const { store } = this.props; - const { id } = getQueryParams(); - - this.userId = id; + const { + store, + query: { id }, + } = this.props; if (id) { await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id), true)).catch( @@ -182,74 +180,76 @@ class Users extends React.Component { pageName="users" itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`} > - <> -
-
-
-
-
- - Users - - - To manage permissions or add users, please visit{' '} - Grafana user management - + {() => ( + <> +
+
+
+
+
+ + Users + + + To manage permissions or add users, please visit{' '} + Grafana user management + +
-
- - - -
- {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( - <> -
- - -
+ +
+ {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( + <> +
+ + +
- + + ) : ( + + You don't have enough permissions to view other users because you are not Admin.{' '} + Click here to open your profile + + } + severity="info" /> - - ) : ( - - You don't have enough permissions to view other users because you are not Admin.{' '} - Click here to open your profile - - } - severity="info" - /> - )} + )} +
+ {userPkToEdit && }
- {userPkToEdit && } -
- + + )} ); @@ -378,7 +378,7 @@ class Users extends React.Component { handleHideUserSettings = () => { this.setState({ userPkToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); + LocationHelper.update({ id: undefined }, 'partial'); }; handleUserUpdate = () => { diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index d753a8e6..93e971a1 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -34,6 +34,7 @@ "name": "Alert Groups", "path": "/a/grafana-oncall-app/?page=incidents", "role": "Viewer", + "defaultNav": true, "addToNav": true }, { diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx index 23854334..63b461be 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx @@ -8,7 +8,17 @@ export function getQueryParams(): any { const searchParams = new URLSearchParams(window.location.search); const result = {}; for (const [key, value] of searchParams) { - result[key] = value; + if (result[key]) { + // key already existing, we're handling an array + if (!Array.isArray(result[key])) { + result[key] = new Array(result[key]); + } + + result[key].push(value); + } else { + result[key] = value; + } } + return result; } diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index d2b4cd59..43288bb3 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -35,7 +35,7 @@ import 'style/vars.css'; import 'style/global.css'; import 'style/utils.css'; -import { isTopNavbar } from './GrafanaPluginRootPage.helpers'; +import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers'; import PluginSetup from './PluginSetup'; export const GrafanaPluginRootPage = (props: AppRootProps) => ( @@ -101,7 +101,7 @@ export const Root = observer((props: AppRootProps) => { 'u-position-relative' )} > - +
); diff --git a/grafana-plugin/src/state/types.ts b/grafana-plugin/src/state/types.ts index 642df664..9f9eccbd 100644 --- a/grafana-plugin/src/state/types.ts +++ b/grafana-plugin/src/state/types.ts @@ -1,9 +1,16 @@ +import { AppPluginMeta, KeyValue } from '@grafana/data'; + import { RootStore } from 'state/index'; export interface WithStoreProps { store: RootStore; } +export interface PageProps { + meta: AppPluginMeta; + query: KeyValue; +} + export interface SelectOption { value: string | number; display_name: string; diff --git a/grafana-plugin/src/style/global.css b/grafana-plugin/src/style/global.css index 07a8af39..fe3e65a2 100644 --- a/grafana-plugin/src/style/global.css +++ b/grafana-plugin/src/style/global.css @@ -39,3 +39,7 @@ .navbarRootFallback { margin-top: 24px; } + +.page-title { + margin-bottom: 16px; +} diff --git a/grafana-plugin/src/utils/LocationHelper.ts b/grafana-plugin/src/utils/LocationHelper.ts new file mode 100644 index 00000000..27bfe38f --- /dev/null +++ b/grafana-plugin/src/utils/LocationHelper.ts @@ -0,0 +1,43 @@ +import { KeyValue } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +import { getQueryParams } from 'plugin/GrafanaPluginRootPage.helpers'; + +class LocationHelper { + update(params: KeyValue, method: 'replace' | 'push' | 'partial') { + const queryParams = getQueryParams(); + + const sortedExistingParams = sort(queryParams); + const sortedNewParams = sort(params); + + if (toQueryString(sortedExistingParams) !== toQueryString(sortedNewParams)) { + if (method === 'partial') { + locationService.partial(params); + } else { + locationService[method](toQueryString(sortedNewParams)); + } + } + } +} + +function toQueryString(queryParams: KeyValue) { + const urlParams = new URLSearchParams(queryParams); + for (const [key, value] of Object.entries(queryParams)) { + if (Array.isArray(value)) { + urlParams.delete(key); + value.forEach((v) => urlParams.append(key, v)); + } + } + return urlParams.toString(); +} + +function sort(object: KeyValue) { + return Object.keys(object) + .sort() + .reduce((obj, key) => { + obj[key] = object[key]; + return obj; + }, {}); +} + +export default new LocationHelper(); diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 6c6098e2..a11f7b61 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1492,6 +1492,32 @@ uplot "1.6.22" xss "1.0.13" +"@grafana/data@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.6.tgz#a8b108fe882a16349e903013e62cb6c741f82135" + integrity sha512-x8a246dsy895TXlQ0aeAawbPOYAvitS+rlFA6t1mT9XvO51LU/UeeNAkGzaCdtO1NBaJ4rDLmj1sQVu++uQvwA== + dependencies: + "@braintree/sanitize-url" "6.0.0" + "@grafana/schema" "9.2.6" + "@types/d3-interpolate" "^1.4.0" + d3-interpolate "1.4.0" + date-fns "2.29.1" + eventemitter3 "4.0.7" + fast_array_intersect "1.1.0" + history "4.10.1" + lodash "4.17.21" + marked "4.1.0" + moment "2.29.4" + moment-timezone "0.5.35" + ol "6.15.1" + papaparse "5.3.2" + regenerator-runtime "0.13.9" + rxjs "7.5.6" + tinycolor2 "1.4.2" + tslib "2.4.0" + uplot "1.6.22" + xss "1.0.13" + "@grafana/e2e-selectors@9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.2.4.tgz#748539cc0313ee1c23055a100313235ef2fca64b" @@ -1501,6 +1527,15 @@ tslib "2.4.0" typescript "4.8.2" +"@grafana/e2e-selectors@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.2.6.tgz#9dc9bd960ffb3edab6927e41848a84adb2fcb889" + integrity sha512-Q6E4CYrf0d0fz6aJmW7neQNr+P1RJ8ocNACk+039LceCVjVECHq9b6UcwCQN1Q3D4Omb9jyMcOLrhCMoTd0nzw== + dependencies: + "@grafana/tsconfig" "^1.2.0-rc1" + tslib "2.4.0" + typescript "4.8.2" + "@grafana/eslint-config@5.0.0", "@grafana/eslint-config@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-5.0.0.tgz#e08a89d378772340bc6cd1872ec4d15666269aba" @@ -1538,10 +1573,17 @@ dependencies: tslib "2.4.0" +"@grafana/schema@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/schema/-/schema-9.2.6.tgz#998d5a40cacf6cfb0e15bb313bf73929e69a2852" + integrity sha512-7eptiYi4N9gnxXxBdcE0Z/WFz41B094gr8F+mh+nEJNPNSz1wZ3/HEaa2cWVhYPXqy1qE1bOQHvSj69erfcqXg== + dependencies: + tslib "2.4.0" + "@grafana/toolkit@^9.2.4": - version "9.2.4" - resolved "https://registry.yarnpkg.com/@grafana/toolkit/-/toolkit-9.2.4.tgz#91ab3c391297a4b1ee5d4a8a0deb9118739b6295" - integrity sha512-EHaXvJAVlN9lf0iQoBh9V6XZxWmgPPRrJazJS551jG1nWnk6ycvz7yqxKhXB4Jk0MnLZGBtPFPh/CczcYoVnrw== + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/toolkit/-/toolkit-9.2.6.tgz#55d424321a65a027f3365c6e0df649bcc1d2c9d6" + integrity sha512-Bs604AR65yb+MuLRUFYh9abaExDraHXh/Mc5Xqo5hDALPs/e5BjYHYB9LAL2KI8ihxu9y6c2hWcpZwno/7k32Q== dependencies: "@babel/core" "7.18.9" "@babel/plugin-proposal-class-properties" "7.18.6" @@ -1555,10 +1597,10 @@ "@babel/preset-env" "7.18.9" "@babel/preset-react" "7.18.6" "@babel/preset-typescript" "7.18.6" - "@grafana/data" "9.2.4" + "@grafana/data" "9.2.6" "@grafana/eslint-config" "5.0.0" "@grafana/tsconfig" "^1.2.0-rc1" - "@grafana/ui" "9.2.4" + "@grafana/ui" "9.2.6" "@jest/core" "27.5.1" "@types/command-exists" "^1.2.0" "@types/eslint" "8.4.1" @@ -1699,6 +1741,72 @@ uplot "1.6.22" uuid "8.3.2" +"@grafana/ui@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.6.tgz#1974edb48b5873c257278886b65ccb1d7e45a33a" + integrity sha512-NM+tNbpks218QHQo9hywr/00fMOjQt0shf3Sq2ywr8e172PXuPxh/AuTACKuVFgW2FY1z5StFUc2B40Dgvn0JQ== + dependencies: + "@emotion/css" "11.9.0" + "@emotion/react" "11.9.3" + "@grafana/data" "9.2.6" + "@grafana/e2e-selectors" "9.2.6" + "@grafana/schema" "9.2.6" + "@monaco-editor/react" "4.4.5" + "@popperjs/core" "2.11.5" + "@react-aria/button" "3.6.1" + "@react-aria/dialog" "3.3.1" + "@react-aria/focus" "3.8.0" + "@react-aria/menu" "3.6.1" + "@react-aria/overlays" "3.10.1" + "@react-aria/utils" "3.13.1" + "@react-stately/menu" "3.4.1" + "@sentry/browser" "6.19.7" + ansicolor "1.1.100" + calculate-size "1.1.1" + classnames "2.3.1" + core-js "3.25.1" + d3 "5.15.0" + date-fns "2.29.1" + hoist-non-react-statics "3.3.2" + immutable "4.1.0" + is-hotkey "0.2.0" + jquery "3.6.0" + lodash "4.17.21" + memoize-one "6.0.0" + moment "2.29.4" + monaco-editor "0.34.0" + ol "6.15.1" + prismjs "1.29.0" + rc-cascader "3.6.1" + rc-drawer "4.4.3" + rc-slider "9.7.5" + rc-time-picker "^3.7.3" + react-beautiful-dnd "13.1.0" + react-calendar "3.7.0" + react-colorful "5.5.1" + react-custom-scrollbars-2 "4.5.0" + react-dropzone "14.2.2" + react-highlight-words "0.18.0" + react-hook-form "7.5.3" + react-inlinesvg "3.0.0" + react-popper "2.3.0" + react-popper-tooltip "^4.3.1" + react-router-dom "^5.2.0" + react-select "5.4.0" + react-select-event "^5.1.0" + react-table "7.8.0" + react-transition-group "4.4.2" + react-use "17.4.0" + react-window "1.8.7" + rxjs "7.5.6" + slate "0.47.9" + slate-plain-serializer "0.7.11" + slate-react "0.22.10" + tinycolor2 "1.4.2" + tslib "2.4.0" + uplot "1.6.22" + uuid "8.3.2" + "@humanwhocodes/config-array@^0.11.6": version "0.11.7" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.7.tgz#38aec044c6c828f6ed51d5d7ae3d9b9faf6dbb0f" @@ -3797,7 +3905,15 @@ ansicolor@1.1.100: resolved "https://registry.yarnpkg.com/ansicolor/-/ansicolor-1.1.100.tgz#811f1afbf726edca3aafb942a14df8351996304a" integrity sha512-Jl0pxRfa9WaQVUX57AB8/V2my6FJxrOR1Pp2qqFbig20QB4HzUoQ48THTKAgHlUCJeQm/s2WoOPcoIDhyCL/kw== -anymatch@^3.0.0, anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@^3.0.0, anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +anymatch@^3.0.3: version "3.1.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== @@ -4328,7 +4444,12 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001426: + version "1.0.30001434" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001434.tgz#ec1ec1cfb0a93a34a0600d37903853030520a4e5" + integrity sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA== + +caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001400: version "1.0.30001431" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz#e7c59bd1bc518fae03a4656be442ce6c4887a795" integrity sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ== @@ -4756,7 +4877,14 @@ copy-webpack-plugin@^9.0.1: schema-utils "^3.1.1" serialize-javascript "^6.0.0" -core-js-compat@^3.21.0, core-js-compat@^3.22.1, core-js-compat@^3.25.1: +core-js-compat@^3.21.0, core-js-compat@^3.22.1: + version "3.26.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.26.1.tgz#0e710b09ebf689d719545ac36e49041850f943df" + integrity sha512-622/KzTudvXCDLRw70iHW4KKs1aGpcRcowGWyYJr2DEBfRrd6hNJybxSWJFuZYD4ma86xhrwDDHxmDaIq4EA8A== + dependencies: + browserslist "^4.21.4" + +core-js-compat@^3.25.1: version "3.26.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.26.0.tgz#94e2cf8ba3e63800c4956ea298a6473bc9d62b44" integrity sha512-piOX9Go+Z4f9ZiBFLnZ5VrOpBl0h7IGCkiFUN11QTe6LjAvOT3ifL/5TdoizMh99hcGy5SoLyWbapIY/PIb/3A== @@ -4789,7 +4917,7 @@ cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" -cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: +cosmiconfig@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== @@ -4800,6 +4928,17 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +cosmiconfig@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" + integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -4857,18 +4996,18 @@ css-in-js-utils@^2.0.0: isobject "^3.0.1" css-loader@^6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.1.tgz#e98106f154f6e1baf3fc3bc455cb9981c1d5fd2e" - integrity sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw== + version "6.7.2" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.2.tgz#26bc22401b5921686a10fbeba75d124228302304" + integrity sha512-oqGbbVcBJkm8QwmnNzrFrWTnudnRZC+1eXikLJl0n4ljcfotgRifpg2a1lKy8jTrc4/d9A/ap1GFq1jDKG7J+Q== dependencies: icss-utils "^5.1.0" - postcss "^8.4.7" + postcss "^8.4.18" postcss-modules-extract-imports "^3.0.0" postcss-modules-local-by-default "^4.0.0" postcss-modules-scope "^3.0.0" postcss-modules-values "^4.0.0" postcss-value-parser "^4.2.0" - semver "^7.3.5" + semver "^7.3.8" css-mediaquery@^0.1.2: version "0.1.2" @@ -5647,7 +5786,7 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: +enhanced-resolve@^5.0.0: version "5.10.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== @@ -5655,6 +5794,14 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.10.0: + version "5.11.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.11.0.tgz#543cf6c847a85adba0c4a5e2170bded4d493919a" + integrity sha512-0Gcraf7gAJSQoPg+bTSXNhuzAYtXqLc4C011vb8S3B8XUSEkGYNBk20c68X9291VF4vvsCD8SPkr6Mza+DwU+g== + dependencies: + graceful-fs "^4.2.9" + tapable "^2.2.0" + enquirer@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" @@ -8565,18 +8712,18 @@ loader-runner@^4.2.0: integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== loader-utils@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.3.tgz#d4b15b8504c63d1fc3f2ade52d41bc8459d6ede1" - integrity sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A== + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" json5 "^2.1.2" loader-utils@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.0.tgz#bcecc51a7898bee7473d4bc6b845b23af8304d4f" - integrity sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ== + version "3.2.1" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576" + integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw== locate-path@^3.0.0: version "3.0.0" @@ -8835,9 +8982,9 @@ mdn-data@2.0.14: integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== memfs@^3.1.2, memfs@^3.4.1: - version "3.4.10" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.10.tgz#4cdff7cfd21351a85e11b08aa276ebf100210a4d" - integrity sha512-0bCUP+L79P4am30yP1msPzApwuMQG23TjwlwdHeEV5MxioDR1a0AgB0T9FfggU52eJuDCq8WVwb5ekznFyWiTQ== + version "3.4.12" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.12.tgz#d00f8ad8dab132dc277c659dc85bfd14b07d03bd" + integrity sha512-BcjuQn6vfqP+k100e0E9m61Hyqa//Brp+I3f0OBmN0ATHlFA8vx3Lt8z57R3u2bPqe3WGDBC+nF72fTH7isyEw== dependencies: fs-monkey "^1.0.3" @@ -8957,9 +9104,9 @@ min-indent@^1.0.0: integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== mini-css-extract-plugin@^2.6.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.1.tgz#9a1251d15f2035c342d99a468ab9da7a0451b71e" - integrity sha512-wd+SD57/K6DiV7jIR34P+s3uckTRuQvx0tKPcvjFlrEylk6P4mQ2KSWk1hblj1Kxaqok7LogKOieygXqBczNlg== + version "2.7.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.0.tgz#d7d9ba0c5b596d155e36e2b174082fc7f010dd64" + integrity sha512-auqtVo8KhTScMsba7MbijqZTfibbXiBNlPAQbsVt7enQfcDYLdgG57eGxMqwVU3mfeWANY4F1wUg+rMF+ycZgw== dependencies: schema-utils "^4.0.0" @@ -9124,9 +9271,9 @@ natural-compare@^1.4.0: integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== needle@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/needle/-/needle-3.1.0.tgz#3bf5cd090c28eb15644181ab6699e027bd6c53c9" - integrity sha512-gCE9weDhjVGCRqS8dwDR/D3GTAeyXLXuqp7I8EzH6DllZGXSUyxuqqLh+YX9rMAWaaTFyVAg6rHGL25dqvczKw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/needle/-/needle-3.2.0.tgz#07d240ebcabfd65c76c03afae7f6defe6469df44" + integrity sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ== dependencies: debug "^3.2.6" iconv-lite "^0.6.3" @@ -10289,7 +10436,15 @@ postcss-selector-not@^5.0.0: dependencies: balanced-match "^1.0.0" -postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: +postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.9: + version "6.0.11" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc" + integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-selector-parser@^6.0.5: version "6.0.10" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== @@ -10330,10 +10485,10 @@ postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0. picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.3.5, postcss@^8.4.12, postcss@^8.4.7: - version "8.4.18" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.18.tgz#6d50046ea7d3d66a85e0e782074e7203bc7fbca2" - integrity sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA== +postcss@^8.3.5, postcss@^8.4.12, postcss@^8.4.18: + version "8.4.19" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc" + integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA== dependencies: nanoid "^3.3.4" picocolors "^1.0.0" @@ -11669,9 +11824,9 @@ signal-exit@^3.0.2, signal-exit@^3.0.3: integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== simple-git@^3.6.0: - version "3.14.1" - resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.14.1.tgz#68018a5f168f8a568862e30b692004b37c3b5ced" - integrity sha512-1ThF4PamK9wBORVGMK9HK5si4zoGS2GpRO7tkAFObA4FZv6dKaCVHLQT+8zlgiBm6K2h+wEU9yOaFCu/SR3OyA== + version "3.15.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.0.tgz#301a95a943c4f9b0a21d051eb6e6d0ffe4c9754f" + integrity sha512-FiWoMPlcYHQ+ApRihUsGjC/ZmIlWj62S6MBCwOunczvXcLQt+9ZdrysDrR6QVepkRQfEAaBXrN2QtJKrN6zbtg== dependencies: "@kwsites/file-exists" "^1.1.1" "@kwsites/promise-deferred" "^1.1.1"