commit
c53faceff9
64 changed files with 1392 additions and 878 deletions
|
|
@ -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
|
||||
|
|
|
|||
20
Makefile
20
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
37
engine/apps/auth_token/migrations/0003_auto_20221121_1610.py
Normal file
37
engine/apps/auth_token/migrations/0003_auto_20221121_1610.py
Normal file
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
0
engine/apps/mobile_app/__init__.py
Normal file
0
engine/apps/mobile_app/__init__.py
Normal file
13
engine/apps/mobile_app/alert_rendering.py
Normal file
13
engine/apps/mobile_app/alert_rendering.py
Normal file
|
|
@ -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")
|
||||
45
engine/apps/mobile_app/auth.py
Normal file
45
engine/apps/mobile_app/auth.py
Normal file
|
|
@ -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
|
||||
56
engine/apps/mobile_app/backend.py
Normal file
56
engine/apps/mobile_app/backend.py
Normal file
|
|
@ -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)
|
||||
54
engine/apps/mobile_app/migrations/0001_initial.py
Normal file
54
engine/apps/mobile_app/migrations/0001_initial.py
Normal file
|
|
@ -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=[],
|
||||
)
|
||||
]
|
||||
0
engine/apps/mobile_app/migrations/__init__.py
Normal file
0
engine/apps/mobile_app/migrations/__init__.py
Normal file
|
|
@ -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
|
||||
95
engine/apps/mobile_app/tasks.py
Normal file
95
engine/apps/mobile_app/tasks.py
Normal file
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
9
engine/apps/mobile_app/urls.py
Normal file
9
engine/apps/mobile_app/urls.py
Normal file
|
|
@ -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
|
||||
12
engine/apps/mobile_app/views.py
Normal file
12
engine/apps/mobile_app/views.py
Normal file
|
|
@ -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,)
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<RealPluginPage {...props}>
|
||||
<Header page={page} backendLicense={store.backendLicense} />
|
||||
<h3 className="page-title">{pages[page].text}</h3>
|
||||
{props.children}
|
||||
</RealPluginPage>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
const handleGoToTemplateSettingsCllick = () => {
|
||||
getLocationSrv().update({ partial: true, query: { tab: 'Autoresolve' } });
|
||||
};
|
||||
const handleGoToTemplateSettingsCllick = () => LocationHelper.update({ tab: 'Autoresolve' }, 'partial');
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<DefaultPageLayoutProps> = 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');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,26 +34,19 @@ const MobileAppVerification = observer((props: MobileAppVerificationProps) => {
|
|||
const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState<boolean>(false);
|
||||
const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState<boolean>(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 (
|
||||
<div className={cx('mobile-app-settings')}>
|
||||
{MobileAppVerificationTokenLoading ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss
Normal file
3
grafana-plugin/src/navbar/LegacyNavTabsBar.module.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
min-width: 1500px;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<TabsBar>
|
||||
<TabsBar className={cx('root')}>
|
||||
{navigationPages.map((page, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, 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 Collapse from 'components/Collapse/Collapse';
|
||||
import EscalationsFilters from 'components/EscalationsFilters/EscalationsFilters';
|
||||
|
|
@ -28,15 +26,16 @@ import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChai
|
|||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.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 './EscalationChains.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface EscalationChainsPageProps extends WithStoreProps, AppRootProps {}
|
||||
interface EscalationChainsPageProps extends WithStoreProps, PageProps {}
|
||||
|
||||
interface EscalationChainsPageState extends PageBaseState {
|
||||
escalationChainsFilters: { searchTerm: string };
|
||||
|
|
@ -103,7 +102,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
const { escalationChainStore } = store;
|
||||
|
||||
this.setState({ selectedEscalationChain: escalationChain }, () => {
|
||||
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<EscalationChainsPageProps, Es
|
|||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
|
||||
</div>
|
||||
{!searchResult || searchResult.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New escalation chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('escalations-list')}>
|
||||
{searchResult ? (
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={selectedEscalationChain}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.setSelectedEscalationChain}
|
||||
>
|
||||
{(item) => <EscalationChainCard id={item.id} />}
|
||||
</GList>
|
||||
) : (
|
||||
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('escalation')}>{this.renderEscalation()}</div>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
|
||||
</div>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Escalations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
|
||||
{!searchResult || searchResult.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
<div className={cx('escalations-list')}>
|
||||
{searchResult ? (
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={selectedEscalationChain}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.setSelectedEscalationChain}
|
||||
>
|
||||
{(item) => <EscalationChainCard id={item.id} />}
|
||||
</GList>
|
||||
) : (
|
||||
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('escalation')}>{this.renderEscalation()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Escalations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCreateEscalationChainModal && (
|
||||
<EscalationChainForm
|
||||
escalationChainId={escalationChainIdToCopy}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showCreateEscalationChainModal: false,
|
||||
escalationChainIdToCopy: undefined,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.handleEscalationChainCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCreateEscalationChainModal && (
|
||||
<EscalationChainForm
|
||||
escalationChainId={escalationChainIdToCopy}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showCreateEscalationChainModal: false,
|
||||
escalationChainIdToCopy: undefined,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.handleEscalationChainCreate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<IncidentPageProps, IncidentPageState>
|
|||
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<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
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<IncidentPageProps, IncidentPageState>
|
|||
return (
|
||||
<PluginPage pageNav={pages['incident'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{errorData.isNotFoundError ? (
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
<Text.Title level={4}>Incident not found</Text.Title>
|
||||
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
|
||||
<Button variant="secondary" icon="arrow-left" size="md">
|
||||
Go to incidents page
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList
|
||||
id={incident.pk}
|
||||
getIncidentDatetimeReference={this.getIncidentDatetimeReference}
|
||||
/>
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
{() => (
|
||||
<div className={cx('root')}>
|
||||
{errorData.isNotFoundError ? (
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
<Text.Title level={4}>Incident not found</Text.Title>
|
||||
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
|
||||
<Button variant="secondary" icon="arrow-left" size="md">
|
||||
Go to incidents page
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
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 && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList
|
||||
id={incident.pk}
|
||||
getIncidentDatetimeReference={this.getIncidentDatetimeReference}
|
||||
/>
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
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 && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
||||
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<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
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<IncidentPageProps, IncidentPageState>
|
|||
};
|
||||
|
||||
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<IncidentPageProps, IncidentPageState>
|
|||
case 'author':
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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<Alert['pk']>;
|
||||
|
|
@ -71,8 +68,10 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
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<IncidentsPageProps, IncidentsPageState>
|
|||
render() {
|
||||
return (
|
||||
<PluginPage pageNav={pages['incidents'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="incidents">
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
</PageErrorHandlingWrapper>
|
||||
<div className={cx('root')}>
|
||||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
|
|
@ -148,7 +145,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
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<IncidentsPageProps, IncidentsPageState>
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
{
|
||||
icon: 'cog',
|
||||
id: 'settings',
|
||||
text: 'Organization Settings',
|
||||
text: 'Settings',
|
||||
hideFromBreadcrumbs: true,
|
||||
path: getPath('settings'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<IntegrationsProps, IntegrationsState> {
|
||||
|
|
@ -62,7 +61,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
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<IntegrationsProps, IntegrationsState>
|
|||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
|
||||
</div>
|
||||
{searchResult?.length ? (
|
||||
<div className={cx('integrations')}>
|
||||
<div className={cx('integrationsList')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('newIntegrationButton')}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('alert-receive-channels-list')}>
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={store.selectedAlertReceiveChannel}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.handleAlertReceiveChannelSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<AlertReceiveChannelCard
|
||||
id={item.id}
|
||||
onShowHeartbeatModal={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: item.id,
|
||||
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GList>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('alert-rules', 'alertRulesBorder')}>
|
||||
<AlertRules
|
||||
alertReceiveChannelId={store.selectedAlertReceiveChannel}
|
||||
onDelete={this.handleDeleteAlertReceiveChannel}
|
||||
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
|
||||
integrationSettingsTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<Tutorial
|
||||
step={TutorialStep.Integrations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
|
||||
{searchResult?.length ? (
|
||||
<div className={cx('integrations')}>
|
||||
<div className={cx('integrationsList')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('newIntegrationButton')}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
<div className={cx('alert-receive-channels-list')}>
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={store.selectedAlertReceiveChannel}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.handleAlertReceiveChannelSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<AlertReceiveChannelCard
|
||||
id={item.id}
|
||||
onShowHeartbeatModal={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: item.id,
|
||||
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GList>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('alert-rules', 'alertRulesBorder')}>
|
||||
<AlertRules
|
||||
alertReceiveChannelId={store.selectedAlertReceiveChannel}
|
||||
onDelete={this.handleDeleteAlertReceiveChannel}
|
||||
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
|
||||
integrationSettingsTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<Tutorial
|
||||
step={TutorialStep.Integrations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
{alertReceiveChannelToShowSettings && (
|
||||
<IntegrationSettings
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
|
||||
}}
|
||||
startTab={integrationSettingsTab}
|
||||
id={alertReceiveChannelToShowSettings}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: undefined,
|
||||
integrationSettingsTab: undefined,
|
||||
});
|
||||
LocationHelper.update({ tab: undefined }, 'partial');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
{alertReceiveChannelToShowSettings && (
|
||||
<IntegrationSettings
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
|
||||
}}
|
||||
startTab={integrationSettingsTab}
|
||||
id={alertReceiveChannelToShowSettings}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: undefined,
|
||||
integrationSettingsTab: undefined,
|
||||
});
|
||||
getLocationSrv().update({ partial: true, query: { tab: undefined } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCreateIntegrationModal && (
|
||||
<CreateAlertReceiveChannelContainer
|
||||
onHide={() => {
|
||||
this.setState({ showCreateIntegrationModal: false });
|
||||
}}
|
||||
onCreate={this.handleCreateNewAlertReceiveChannel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{showCreateIntegrationModal && (
|
||||
<CreateAlertReceiveChannelContainer
|
||||
onHide={() => {
|
||||
this.setState({ showCreateIntegrationModal: false });
|
||||
}}
|
||||
onCreate={this.handleCreateNewAlertReceiveChannel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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(<OutgoingWebhooks {...getProps()} />);
|
||||
|
||||
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(<OutgoingWebhooks {...getProps(id)} />);
|
||||
|
||||
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<HTMLElement>('test__outgoingWebhookEditForm');
|
||||
}
|
||||
});
|
||||
|
|
@ -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<OutgoingWebhooksProps, OutgoingWe
|
|||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
private outgoingWebhookId: string;
|
||||
|
||||
async componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.outgoingWebhookId !== getQueryParams()['id']) {
|
||||
componentDidUpdate(prevProps: OutgoingWebhooksProps) {
|
||||
if (prevProps.query.id !== this.props.query.id) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
|
@ -61,10 +57,10 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
outgoingWebhookIdToEdit: undefined,
|
||||
})); // reset state on query parse
|
||||
|
||||
const { store } = this.props;
|
||||
const { id } = getQueryParams();
|
||||
|
||||
this.outgoingWebhookId = id;
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
|
|
@ -122,43 +118,45 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<div className="u-pull-right">
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<div className="u-pull-right">
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={webhooks}
|
||||
/>
|
||||
</div>
|
||||
{outgoingWebhookIdToEdit && (
|
||||
<OutgoingWebhookForm
|
||||
id={outgoingWebhookIdToEdit}
|
||||
onUpdate={this.update}
|
||||
onHide={this.handleOutgoingWebhookFormHide}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={webhooks}
|
||||
/>
|
||||
</div>
|
||||
{outgoingWebhookIdToEdit && (
|
||||
<OutgoingWebhookForm
|
||||
id={outgoingWebhookIdToEdit}
|
||||
onUpdate={this.update}
|
||||
onHide={this.handleOutgoingWebhookFormHide}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
|
|
@ -194,14 +192,14 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
return () => {
|
||||
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');
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SchedulePageProps, SchedulePageState>
|
|||
}
|
||||
|
||||
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<SchedulePageProps, SchedulePageState>
|
|||
}
|
||||
|
||||
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<SchedulePageProps, SchedulePageState>
|
|||
return (
|
||||
<PluginPage pageNav={pages['schedule'].getPageNav()}>
|
||||
<PageErrorHandlingWrapper pageName="schedules">
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect
|
||||
value={currentTimezone}
|
||||
users={users}
|
||||
onChange={this.handleTimezoneChange}
|
||||
/>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title
|
||||
editable
|
||||
editModalTitle="Schedule name"
|
||||
level={2}
|
||||
onTextChange={this.handleNameChange}
|
||||
>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
{(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect
|
||||
value={currentTimezone}
|
||||
users={users}
|
||||
onChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
{(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
onClick={() => {
|
||||
this.setState({ showEditForm: true });
|
||||
}}
|
||||
/>
|
||||
<WithConfirm>
|
||||
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
|
||||
</WithConfirm>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('users-timezones')}>
|
||||
<UsersTimezones
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
onCallNow={schedule?.on_call_now || []}
|
||||
userIds={
|
||||
scheduleStore.relatedUsers[scheduleId]
|
||||
? Object.keys(scheduleStore.relatedUsers[scheduleId])
|
||||
: []
|
||||
}
|
||||
tz={currentTimezone}
|
||||
onTzChange={this.handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
<div className={cx('rotations')}>
|
||||
<div className={cx('controls')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleTodayClick}>
|
||||
Today
|
||||
</Button>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button variant="secondary" onClick={this.handleLeftClick}>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleRightClick}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
</Text.Title>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
</Text.Title>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
{showEditForm && (
|
||||
<ScheduleForm
|
||||
id={schedule.id}
|
||||
onUpdate={this.update}
|
||||
onHide={() => {
|
||||
this.setState({ showEditForm: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
|
|
@ -293,8 +306,10 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
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<SchedulePageProps, SchedulePageState>
|
|||
};
|
||||
|
||||
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'));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SchedulesPageProps, SchedulesPageSta
|
|||
|
||||
handleCreateSchedule = (data: Schedule) => {
|
||||
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<SchedulesPageProps, SchedulesPageSta
|
|||
};
|
||||
|
||||
getScheduleClickHandler = (scheduleId: Schedule['id']) => {
|
||||
return () => {
|
||||
getLocationSrv().update({ query: { page: 'schedule', id: scheduleId } });
|
||||
};
|
||||
return () => LocationHelper.update({ page: 'schedule', id: scheduleId }, 'replace');
|
||||
};
|
||||
|
||||
renderType = (value: number) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<UsersProps, UsersState> {
|
|||
|
||||
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<UsersProps, UsersState> {
|
|||
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<UsersProps, UsersState> {
|
|||
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<UsersProps, UsersState> {
|
|||
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<UsersProps, UsersState> {
|
|||
pageName="users"
|
||||
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
|
||||
>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root', 'TEST-users-page')}>
|
||||
<div className={cx('users-header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
</Text>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root', 'TEST-users-page')}>
|
||||
<div className={cx('users-header')}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PluginLink partial query={{ id: 'me' }}>
|
||||
<Button variant="primary" icon="user">
|
||||
View my profile
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</div>
|
||||
{store.isUserActionAllowed(UserAction.ViewOtherUsers) ? (
|
||||
<>
|
||||
<div className={cx('user-filters-container')}>
|
||||
<UsersFilters
|
||||
className={cx('users-filters')}
|
||||
value={usersFilters}
|
||||
onChange={this.handleUsersFiltersChange}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="times"
|
||||
onClick={handleClear}
|
||||
className={cx('searchIntegrationClear')}
|
||||
>
|
||||
Clear filters
|
||||
<PluginLink partial query={{ id: 'me' }}>
|
||||
<Button variant="primary" icon="user">
|
||||
View my profile
|
||||
</Button>
|
||||
</div>
|
||||
</PluginLink>
|
||||
</div>
|
||||
{store.isUserActionAllowed(UserAction.ViewOtherUsers) ? (
|
||||
<>
|
||||
<div className={cx('user-filters-container')}>
|
||||
<UsersFilters
|
||||
className={cx('users-filters')}
|
||||
value={usersFilters}
|
||||
onChange={this.handleUsersFiltersChange}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="times"
|
||||
onClick={handleClear}
|
||||
className={cx('searchIntegrationClear')}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GTable
|
||||
emptyText={results ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
columns={columns}
|
||||
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
<GTable
|
||||
emptyText={results ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
columns={columns}
|
||||
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
|
||||
pagination={{
|
||||
page,
|
||||
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
|
||||
onChange: this.handleChangePage,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
/* @ts-ignore */
|
||||
title={
|
||||
<>
|
||||
You don't have enough permissions to view other users because you are not Admin.{' '}
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
|
||||
</>
|
||||
}
|
||||
severity="info"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
/* @ts-ignore */
|
||||
title={
|
||||
<>
|
||||
You don't have enough permissions to view other users because you are not Admin.{' '}
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
|
||||
</>
|
||||
}
|
||||
severity="info"
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
|
||||
</div>
|
||||
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
</PluginPage>
|
||||
);
|
||||
|
|
@ -378,7 +378,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
handleHideUserSettings = () => {
|
||||
this.setState({ userPkToEdit: undefined });
|
||||
|
||||
getLocationSrv().update({ partial: true, query: { id: undefined } });
|
||||
LocationHelper.update({ id: undefined }, 'partial');
|
||||
};
|
||||
|
||||
handleUserUpdate = () => {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"name": "Alert Groups",
|
||||
"path": "/a/grafana-oncall-app/?page=incidents",
|
||||
"role": "Viewer",
|
||||
"defaultNav": true,
|
||||
"addToNav": true
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
<Page {...props} path={pathWithoutLeadingSlash} store={store} />
|
||||
<Page {...props} query={...getQueryParams()} path={pathWithoutLeadingSlash} store={store} />
|
||||
</div>
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { AppPluginMeta, KeyValue } from '@grafana/data';
|
||||
|
||||
import { RootStore } from 'state/index';
|
||||
|
||||
export interface WithStoreProps {
|
||||
store: RootStore;
|
||||
}
|
||||
|
||||
export interface PageProps<T extends KeyValue = KeyValue> {
|
||||
meta: AppPluginMeta<T>;
|
||||
query: KeyValue;
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
display_name: string;
|
||||
|
|
|
|||
|
|
@ -39,3 +39,7 @@
|
|||
.navbarRootFallback {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
|
|||
43
grafana-plugin/src/utils/LocationHelper.ts
Normal file
43
grafana-plugin/src/utils/LocationHelper.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue