v1.8.0
This commit is contained in:
commit
46017acbd8
66 changed files with 874 additions and 508 deletions
9
Makefile
9
Makefile
|
|
@ -156,7 +156,8 @@ build: ## rebuild images (e.g. when changing requirements.txt)
|
|||
cleanup: stop ## this will remove all of the images, containers, volumes, and networks
|
||||
## associated with your local OnCall developer setup
|
||||
$(call echo_deprecation_message)
|
||||
docker system prune --filter label="$(DOCKER_COMPOSE_DEV_LABEL)" --all --volumes
|
||||
docker system prune --filter label="$(DOCKER_COMPOSE_DEV_LABEL)" --all --volumes --force
|
||||
docker volume prune --filter label="$(DOCKER_COMPOSE_DEV_LABEL)" --all --force
|
||||
|
||||
install-pre-commit:
|
||||
@if [ ! -x "$$(command -v pre-commit)" ]; then \
|
||||
|
|
@ -245,7 +246,7 @@ pip-compile-locked-dependencies: ## compile engine requirements.txt files
|
|||
define backend_command
|
||||
export `grep -v '^#' $(DEV_ENV_FILE) | xargs -0` && \
|
||||
export BROKER_TYPE=$(BROKER_TYPE) && \
|
||||
. ./venv/bin/activate && \
|
||||
. $(VENV_DIR)/bin/activate && \
|
||||
cd engine && \
|
||||
$(1)
|
||||
endef
|
||||
|
|
@ -253,9 +254,9 @@ endef
|
|||
backend-bootstrap:
|
||||
python3.12 -m venv $(VENV_DIR)
|
||||
$(VENV_DIR)/bin/pip install -U pip wheel uv
|
||||
$(VENV_DIR)/bin/uv pip sync $(REQUIREMENTS_TXT) $(REQUIREMENTS_DEV_TXT)
|
||||
$(VENV_DIR)/bin/uv pip sync --python=$(VENV_DIR)/bin/python $(REQUIREMENTS_TXT) $(REQUIREMENTS_DEV_TXT)
|
||||
@if [ -f $(REQUIREMENTS_ENTERPRISE_TXT) ]; then \
|
||||
$(VENV_DIR)/bin/uv pip install -r $(REQUIREMENTS_ENTERPRISE_TXT); \
|
||||
$(VENV_DIR)/bin/uv pip install --python=$(VENV_DIR)/bin/python -r $(REQUIREMENTS_ENTERPRISE_TXT); \
|
||||
fi
|
||||
|
||||
backend-migrate:
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ from an on-call schedule.
|
|||
* `Notify all users from a team` - send a notification to all users in a team.
|
||||
* `Resolve incident automatically` - resolve the alert group right now with status
|
||||
`Resolved automatically`.
|
||||
* `Notify whole slack channel` - send a notification to the users in the slack channel. These users will be notified
|
||||
* `Escalate to all Slack channel members` - send a notification to the users in the slack channel. These users will be notified
|
||||
via the method configured in their user profile.
|
||||
* `Notify Slack User Group` - send a notification to each member of a slack user group. These users will be notified
|
||||
via the method configured in their user profile.
|
||||
|
|
@ -97,7 +97,7 @@ Useful when you want to get escalation only during working hours
|
|||
passes some threshold
|
||||
* `Repeat escalation from beginning (5 times max)` - loop the escalation chain
|
||||
|
||||
> **Note:** Both "**Notify whole Slack channel**" and "**Notify Slack User Group**" will filter OnCall registered users
|
||||
> **Note:** Both "**Escalate to all Slack channel members**" and "**Notify Slack User Group**" will filter OnCall registered users
|
||||
matching the users in the Slack channel or Slack User Group with their profiles linked to their Slack accounts (ie. users
|
||||
should have linked their Slack and OnCall users). In both cases, the filtered users satisfying the criteria above are
|
||||
notified following their respective notification policies. However, to avoid **spamming** the Slack channel/thread,
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ and users:
|
|||
Once your Slack integration is configured you can configure Escalation Chains to notify via Slack messages for alerts
|
||||
in Grafana OnCall.
|
||||
|
||||
There are two Slack notification options that you can configure into escalation chains, notify whole Slack channel and
|
||||
There are two Slack notification options that you can configure into escalation chains, escalate to all Slack channel members and
|
||||
notify Slack user group:
|
||||
|
||||
1. In Grafana OnCall, navigate to the **Escalation Chains** tab then select an existing escalation chain or
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ The above command returns JSON structured in the following way:
|
|||
| ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `user_id` | Yes | User ID |
|
||||
| `position` | Optional | Personal notification rules execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down on the list. |
|
||||
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`. |
|
||||
| `duration` | Optional | A time in secs when type `wait` is chosen for `type`. |
|
||||
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`. |
|
||||
| `duration` | Optional | A time in seconds to wait (when `type=wait`). Can be one of 60, 300, 900, 1800, or 3600. |
|
||||
| `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. |
|
||||
|
||||
**HTTP request**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ if typing.TYPE_CHECKING:
|
|||
from django.db.models.manager import RelatedManager
|
||||
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, ResolutionNote
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
|
||||
from apps.user_management.models import User
|
||||
|
||||
|
||||
class IncidentLogBuilder:
|
||||
|
|
@ -578,7 +579,9 @@ class IncidentLogBuilder:
|
|||
escalation_plan_dict.setdefault(timedelta, []).append(plan)
|
||||
return escalation_plan_dict
|
||||
|
||||
def _render_user_notification_line(self, user_to_notify, notification_policy, for_slack=False):
|
||||
def _render_user_notification_line(
|
||||
self, user_to_notify: "User", notification_policy: "UserNotificationPolicy", for_slack=False
|
||||
):
|
||||
"""
|
||||
Renders user notification plan line
|
||||
:param user_to_notify:
|
||||
|
|
@ -611,7 +614,9 @@ class IncidentLogBuilder:
|
|||
result += f"inviting {user_verbal} but notification channel is unspecified"
|
||||
return result
|
||||
|
||||
def _get_notification_plan_for_user(self, user_to_notify, future_step=False, important=False, for_slack=False):
|
||||
def _get_notification_plan_for_user(
|
||||
self, user_to_notify: "User", future_step=False, important=False, for_slack=False
|
||||
):
|
||||
"""
|
||||
Renders user notification plan
|
||||
:param user_to_notify:
|
||||
|
|
@ -665,7 +670,7 @@ class IncidentLogBuilder:
|
|||
# last passed step order + 1
|
||||
notification_policy_order = last_user_log.notification_policy.order + 1
|
||||
|
||||
notification_policies = user_to_notify.get_or_create_notification_policies(important=important)
|
||||
notification_policies = user_to_notify.get_notification_policies_or_use_default_fallback(important=important)
|
||||
|
||||
for notification_policy in notification_policies:
|
||||
future_notification = notification_policy.order >= notification_policy_order
|
||||
|
|
|
|||
|
|
@ -128,7 +128,10 @@ class EscalationPolicy(OrderedModel):
|
|||
),
|
||||
STEP_FINAL_RESOLVE: ("Resolve alert group automatically", "Resolve alert group automatically"),
|
||||
# Slack
|
||||
STEP_FINAL_NOTIFYALL: ("Notify whole Slack channel", "Notify whole Slack channel"),
|
||||
STEP_FINAL_NOTIFYALL: (
|
||||
"Escalate to all Slack channel members (use with caution)",
|
||||
"Escalate to all Slack channel members (use with caution)",
|
||||
),
|
||||
STEP_NOTIFY_GROUP: (
|
||||
"Start {{importance}} notification for everyone from Slack User Group {{slack_user_group}}",
|
||||
"Notify Slack User Group",
|
||||
|
|
|
|||
|
|
@ -83,22 +83,22 @@ def notify_group_task(alert_group_pk, escalation_policy_snapshot_order=None):
|
|||
continue
|
||||
|
||||
important = escalation_policy_step == EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT
|
||||
notification_policies = user.get_or_create_notification_policies(important=important)
|
||||
notification_policies = user.get_notification_policies_or_use_default_fallback(important=important)
|
||||
|
||||
if notification_policies:
|
||||
usergroup_notification_plan += "\n_{} (".format(
|
||||
step.get_user_notification_message_for_thread_for_usergroup(user, notification_policies.first())
|
||||
step.get_user_notification_message_for_thread_for_usergroup(user, notification_policies[0])
|
||||
)
|
||||
|
||||
notification_channels = []
|
||||
if notification_policies.filter(step=UserNotificationPolicy.Step.NOTIFY).count() == 0:
|
||||
else:
|
||||
usergroup_notification_plan += "Empty notifications"
|
||||
|
||||
notification_channels = []
|
||||
for notification_policy in notification_policies:
|
||||
if notification_policy.step == UserNotificationPolicy.Step.NOTIFY:
|
||||
notification_channels.append(
|
||||
UserNotificationPolicy.NotificationChannel(notification_policy.notify_by).label
|
||||
)
|
||||
|
||||
usergroup_notification_plan += "→".join(notification_channels) + ")_"
|
||||
reason = f"Membership in <!subteam^{usergroup.slack_id}> User Group"
|
||||
|
||||
|
|
|
|||
|
|
@ -72,20 +72,20 @@ def notify_user_task(
|
|||
user_has_notification = UserHasNotification.objects.filter(pk=user_has_notification.pk).select_for_update()[0]
|
||||
|
||||
if previous_notification_policy_pk is None:
|
||||
notification_policy = user.get_or_create_notification_policies(important=important).first()
|
||||
if notification_policy is None:
|
||||
notification_policies = user.get_notification_policies_or_use_default_fallback(important=important)
|
||||
if not notification_policies:
|
||||
task_logger.info(
|
||||
f"notify_user_task: Failed to notify. No notification policies. user_id={user_pk} alert_group_id={alert_group_pk} important={important}"
|
||||
)
|
||||
return
|
||||
|
||||
# Here we collect a brief overview of notification steps configured for user to send it to thread.
|
||||
collected_steps_ids = []
|
||||
next_notification_policy = notification_policy.next()
|
||||
while next_notification_policy is not None:
|
||||
if next_notification_policy.step == UserNotificationPolicy.Step.NOTIFY:
|
||||
if next_notification_policy.notify_by not in collected_steps_ids:
|
||||
collected_steps_ids.append(next_notification_policy.notify_by)
|
||||
next_notification_policy = next_notification_policy.next()
|
||||
for notification_policy in notification_policies:
|
||||
if notification_policy.step == UserNotificationPolicy.Step.NOTIFY:
|
||||
if notification_policy.notify_by not in collected_steps_ids:
|
||||
collected_steps_ids.append(notification_policy.notify_by)
|
||||
|
||||
collected_steps = ", ".join(
|
||||
UserNotificationPolicy.NotificationChannel(step_id).label for step_id in collected_steps_ids
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from apps.phone_notifications.exceptions import (
|
|||
FailedToStartVerification,
|
||||
NumberAlreadyVerified,
|
||||
NumberNotVerified,
|
||||
PhoneNumberBanned,
|
||||
ProviderNotSupports,
|
||||
)
|
||||
from apps.phone_notifications.phone_backend import PhoneBackend
|
||||
|
|
@ -478,6 +479,8 @@ class UserView(
|
|||
phone_backend.send_verification_sms(user)
|
||||
except NumberAlreadyVerified:
|
||||
return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST)
|
||||
except PhoneNumberBanned:
|
||||
return Response("Phone number has been banned", status=status.HTTP_403_FORBIDDEN)
|
||||
except FailedToStartVerification as e:
|
||||
return handle_phone_notificator_failed(e)
|
||||
except ProviderNotSupports:
|
||||
|
|
@ -505,6 +508,8 @@ class UserView(
|
|||
phone_backend.make_verification_call(user)
|
||||
except NumberAlreadyVerified:
|
||||
return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST)
|
||||
except PhoneNumberBanned:
|
||||
return Response("Phone number has been banned", status=status.HTTP_403_FORBIDDEN)
|
||||
except FailedToStartVerification as e:
|
||||
return handle_phone_notificator_failed(e)
|
||||
except ProviderNotSupports:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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.mixins import UpdateSerializerMixin
|
||||
from common.exceptions import UserNotificationPolicyCouldNotBeDeleted
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
from common.ordered_model.viewset import OrderedModelViewSet
|
||||
|
||||
|
|
@ -73,7 +72,7 @@ class UserNotificationPolicyView(UpdateSerializerMixin, OrderedModelViewSet):
|
|||
target_user = User.objects.get(public_primary_key=user_id)
|
||||
except User.DoesNotExist:
|
||||
raise BadRequest(detail="User does not exist")
|
||||
queryset = target_user.get_or_create_notification_policies(important=important)
|
||||
queryset = UserNotificationPolicy.objects.filter(user=target_user, important=important)
|
||||
return self.serializer_class.setup_eager_loading(queryset)
|
||||
|
||||
def get_object(self):
|
||||
|
|
@ -119,10 +118,7 @@ class UserNotificationPolicyView(UpdateSerializerMixin, OrderedModelViewSet):
|
|||
def perform_destroy(self, instance):
|
||||
user = instance.user
|
||||
prev_state = user.insight_logs_serialized
|
||||
try:
|
||||
instance.delete()
|
||||
except UserNotificationPolicyCouldNotBeDeleted:
|
||||
raise BadRequest(detail="Can't delete last user notification policy")
|
||||
instance.delete()
|
||||
new_state = user.insight_logs_serialized
|
||||
write_resource_insight_log(
|
||||
instance=user,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from rest_framework import serializers
|
|||
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
|
||||
from apps.alerts.models import Alert, AlertGroup
|
||||
from apps.api.serializers.alert_group import AlertGroupFieldsCacheSerializerMixin
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -21,12 +22,25 @@ class AlertSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class LabelsSerializer(serializers.ModelSerializer):
|
||||
key = serializers.CharField(read_only=True, source="key_name")
|
||||
value = serializers.CharField(read_only=True, source="value_name")
|
||||
|
||||
class Meta:
|
||||
model = AlertGroupAssociatedLabel
|
||||
fields = [
|
||||
"key",
|
||||
"value",
|
||||
]
|
||||
|
||||
|
||||
class AlertGroupSerializer(serializers.ModelSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
status = serializers.SerializerMethodField(source="get_status")
|
||||
link = serializers.CharField(read_only=True, source="web_link")
|
||||
title = serializers.CharField(read_only=True, source="long_verbose_name_without_formatting")
|
||||
alerts = AlertSerializer(many=True, read_only=True)
|
||||
labels = LabelsSerializer(many=True, read_only=True)
|
||||
|
||||
render_for_web = serializers.SerializerMethodField()
|
||||
|
||||
|
|
@ -53,4 +67,5 @@ class AlertGroupSerializer(serializers.ModelSerializer):
|
|||
"alerts",
|
||||
"title",
|
||||
"render_for_web",
|
||||
"labels",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import pytest
|
|||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.metrics_exporter.constants import SERVICE_LABEL
|
||||
from apps.metrics_exporter.tests.conftest import METRICS_TEST_SERVICE_NAME
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_details(
|
||||
|
|
@ -9,6 +12,7 @@ def test_alert_group_details(
|
|||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_label_association,
|
||||
settings,
|
||||
):
|
||||
settings.GRAFANA_INCIDENT_STATIC_API_KEY = "test-key"
|
||||
|
|
@ -40,6 +44,44 @@ def test_alert_group_details(
|
|||
"payload": alert_payload,
|
||||
}
|
||||
],
|
||||
"labels": [],
|
||||
"render_for_web": {
|
||||
"title": "title: bar",
|
||||
"message": "<p>Something foo + baz</p>",
|
||||
"image_url": "http://foo",
|
||||
"source_link": None,
|
||||
},
|
||||
}
|
||||
assert response.json() == expected
|
||||
# enable labels feature flag
|
||||
settings.FEATURE_LABELS_ENABLED_FOR_ALL = True
|
||||
alert_group_with_labels = make_alert_group(alert_receive_channel)
|
||||
alert_with_labels = make_alert(alert_group_with_labels, alert_payload)
|
||||
_ = make_alert_group_label_association(
|
||||
organization, alert_group_with_labels, key_name=SERVICE_LABEL, value_name=METRICS_TEST_SERVICE_NAME
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"api-gi:alert-groups-detail", kwargs={"public_primary_key": alert_group_with_labels.public_primary_key}
|
||||
)
|
||||
response = client.get(url, format="json", **headers)
|
||||
expected = {
|
||||
"id": alert_group_with_labels.public_primary_key,
|
||||
"link": alert_group_with_labels.web_link,
|
||||
"status": "new",
|
||||
"title": alert_group_with_labels.long_verbose_name_without_formatting,
|
||||
"alerts": [
|
||||
{
|
||||
"id_oncall": alert_with_labels.public_primary_key,
|
||||
"payload": alert_payload,
|
||||
}
|
||||
],
|
||||
"labels": [
|
||||
{
|
||||
"key": "service_name",
|
||||
"value": "test_service",
|
||||
}
|
||||
],
|
||||
"render_for_web": {
|
||||
"title": "title: bar",
|
||||
"message": "<p>Something foo + baz</p>",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ from typing import Tuple
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import IntegrityError, models
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.base.messaging import get_messaging_backends
|
||||
from apps.user_management.models import User
|
||||
from common.exceptions import UserNotificationPolicyCouldNotBeDeleted
|
||||
from common.ordered_model.ordered_model import OrderedModel
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
|
|
@ -66,32 +65,7 @@ def validate_channel_choice(value):
|
|||
raise ValidationError("%(value)s is not a valid option", params={"value": value})
|
||||
|
||||
|
||||
class UserNotificationPolicyQuerySet(models.QuerySet):
|
||||
def create_default_policies_for_user(self, user: User) -> None:
|
||||
if user.notification_policies.filter(important=False).exists():
|
||||
return
|
||||
|
||||
policies_to_create = user.default_notification_policies_defaults
|
||||
|
||||
try:
|
||||
super().bulk_create(policies_to_create)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
def create_important_policies_for_user(self, user: User) -> None:
|
||||
if user.notification_policies.filter(important=True).exists():
|
||||
return
|
||||
|
||||
policies_to_create = user.important_notification_policies_defaults
|
||||
|
||||
try:
|
||||
super().bulk_create(policies_to_create)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
class UserNotificationPolicy(OrderedModel):
|
||||
objects = UserNotificationPolicyQuerySet.as_manager()
|
||||
order_with_respect_to = ("user_id", "important")
|
||||
|
||||
public_primary_key = models.CharField(
|
||||
|
|
@ -171,12 +145,6 @@ class UserNotificationPolicy(OrderedModel):
|
|||
else:
|
||||
return "Not set"
|
||||
|
||||
def delete(self):
|
||||
if UserNotificationPolicy.objects.filter(important=self.important, user=self.user).count() == 1:
|
||||
raise UserNotificationPolicyCouldNotBeDeleted("Can't delete last user notification policy")
|
||||
else:
|
||||
super().delete()
|
||||
|
||||
|
||||
class NotificationChannelOptions:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from apps.base.models.user_notification_policy import (
|
|||
validate_channel_choice,
|
||||
)
|
||||
from apps.base.tests.messaging_backend import TestOnlyBackend
|
||||
from common.exceptions import UserNotificationPolicyCouldNotBeDeleted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -84,7 +83,7 @@ def test_extra_messaging_backends_details():
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unable_to_delete_last_notification_policy(
|
||||
def test_can_delete_last_notification_policy(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_user_notification_policy,
|
||||
|
|
@ -101,5 +100,4 @@ def test_unable_to_delete_last_notification_policy(
|
|||
)
|
||||
|
||||
first_policy.delete()
|
||||
with pytest.raises(UserNotificationPolicyCouldNotBeDeleted):
|
||||
second_policy.delete()
|
||||
second_policy.delete()
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class ChatopsProxyAPIClient:
|
|||
|
||||
# OnCall Tenant
|
||||
def register_tenant(
|
||||
self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int
|
||||
self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int, stack_slug: str
|
||||
) -> tuple[Tenant, requests.models.Response]:
|
||||
url = f"{self.api_base_url}/tenants/register"
|
||||
d = {
|
||||
|
|
@ -74,6 +74,7 @@ class ChatopsProxyAPIClient:
|
|||
"cluster_slug": cluster_slug,
|
||||
"service_type": service_type,
|
||||
"stack_id": stack_id,
|
||||
"stack_slug": stack_slug,
|
||||
}
|
||||
}
|
||||
response = requests.post(url=url, json=d, headers=self._headers)
|
||||
|
|
@ -170,6 +171,22 @@ class ChatopsProxyAPIClient:
|
|||
self._check_response(response)
|
||||
return OAuthInstallation(**response.json()["oauth_installation"]), response
|
||||
|
||||
def delete_oauth_installation(
|
||||
self,
|
||||
stack_id: int,
|
||||
provider_type: str,
|
||||
grafana_user_id: int,
|
||||
) -> tuple[bool, requests.models.Response]:
|
||||
url = f"{self.api_base_url}/oauth_installations/uninstall"
|
||||
d = {
|
||||
"stack_id": stack_id,
|
||||
"provider_type": provider_type,
|
||||
"grafana_user_id": grafana_user_id,
|
||||
}
|
||||
response = requests.post(url=url, json=d, headers=self._headers)
|
||||
self._check_response(response)
|
||||
return response.json()["removed"], response
|
||||
|
||||
def _check_response(self, response: requests.models.Response):
|
||||
"""
|
||||
Wraps an exceptional response to ChatopsProxyAPIException
|
||||
|
|
|
|||
18
engine/apps/chatops_proxy/register_oncall_tenant.py
Normal file
18
engine/apps/chatops_proxy/register_oncall_tenant.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# register_oncall_tenant moved to separate file from engine/apps/chatops_proxy/utils.py to avoid circular imports.
|
||||
from django.conf import settings
|
||||
|
||||
from apps.chatops_proxy.client import SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient
|
||||
|
||||
|
||||
def register_oncall_tenant(org):
|
||||
"""
|
||||
register_oncall_tenant registers oncall organization as a tenant in chatops-proxy.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
client.register_tenant(
|
||||
str(org.uuid),
|
||||
settings.ONCALL_BACKEND_REGION,
|
||||
SERVICE_TYPE_ONCALL,
|
||||
org.stack_id,
|
||||
org.stack_slug,
|
||||
)
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
from functools import partial
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||
from .register_oncall_tenant import register_oncall_tenant
|
||||
|
||||
task_logger = get_task_logger(__name__)
|
||||
|
||||
|
|
@ -18,26 +21,42 @@ def register_oncall_tenant_async(**kwargs):
|
|||
cluster_slug = kwargs.get("cluster_slug")
|
||||
service_type = kwargs.get("service_type")
|
||||
stack_id = kwargs.get("stack_id")
|
||||
stack_slug = kwargs.get("stack_slug")
|
||||
org_id = kwargs.get("org_id")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(service_tenant_id, cluster_slug, service_type, stack_id)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
task_logger.error(
|
||||
f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}'
|
||||
# Temporary hack to support both old and new set of arguments
|
||||
if org_id:
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
try:
|
||||
org = Organization.objects.get(pk=org_id)
|
||||
except Organization.DoesNotExist:
|
||||
task_logger.info(f"register_oncall_tenant_async: organization {org_id} was not found")
|
||||
return
|
||||
register_func = partial(register_oncall_tenant, org)
|
||||
else:
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
register_func = partial(
|
||||
client.register_tenant, service_tenant_id, cluster_slug, service_type, stack_id, stack_slug
|
||||
)
|
||||
try:
|
||||
register_func()
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
# TODO: remove this check once new upsert tenant api is released
|
||||
if api_exc.status == 409:
|
||||
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
|
||||
# Not retrying in this case, because manual conflict-resolution needed.
|
||||
task_logger.info(f"register_oncall_tenant_async: tenant for organization {org_id} already exists")
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
task_logger.error(
|
||||
f"register_oncall_tenant_async: failed to register tenant for organization {org_id}: {api_exc.msg}"
|
||||
)
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
# Keep retrying task for any other exceptions too
|
||||
task_logger.error(
|
||||
f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}"
|
||||
)
|
||||
task_logger.error(f"register_oncall_tenant_async: failed to register tenant for organization {org_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
|
|
@ -120,3 +139,48 @@ def unlink_slack_team_async(**kwargs):
|
|||
f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=0,
|
||||
)
|
||||
def start_sync_org_with_chatops_proxy():
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
organization_qs = Organization.objects.all()
|
||||
organization_pks = organization_qs.values_list("pk", flat=True)
|
||||
|
||||
max_countdown = 60 * 30 # 30 minutes, feel free to adjust
|
||||
for idx, organization_pk in enumerate(organization_pks):
|
||||
countdown = idx % max_countdown
|
||||
sync_org_with_chatops_proxy.apply_async(kwargs={"org_id": organization_pk}, countdown=countdown)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=3,
|
||||
)
|
||||
def sync_org_with_chatops_proxy(**kwargs):
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
org_id = kwargs.get("org_id")
|
||||
task_logger.info(f"sync_org_with_chatops_proxy: started org_id={org_id}")
|
||||
|
||||
try:
|
||||
org = Organization.objects.get(pk=org_id)
|
||||
except Organization.DoesNotExist:
|
||||
task_logger.info(f"sync_org_with_chatops_proxy: organization {org_id} was not found")
|
||||
return
|
||||
|
||||
try:
|
||||
register_oncall_tenant(org)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
# TODO: once tenants upsert api is released, remove this check
|
||||
if api_exc.status == 409:
|
||||
task_logger.info(f"sync_org_with_chatops_proxy: tenant for organization {org_id} already exists")
|
||||
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
|
||||
return
|
||||
raise api_exc
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import typing
|
|||
from django.conf import settings
|
||||
|
||||
from .client import PROVIDER_TYPE_SLACK, SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||
from .register_oncall_tenant import register_oncall_tenant
|
||||
from .tasks import (
|
||||
link_slack_team_async,
|
||||
register_oncall_tenant_async,
|
||||
|
|
@ -48,31 +49,24 @@ def get_slack_oauth_response_from_chatops_proxy(stack_id) -> dict:
|
|||
return slack_installation.oauth_response
|
||||
|
||||
|
||||
def register_oncall_tenant(service_tenant_id: str, cluster_slug: str, stack_id: int):
|
||||
def register_oncall_tenant_with_async_fallback(org):
|
||||
"""
|
||||
register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions
|
||||
to make sure that tenant is registered.
|
||||
First attempt is synchronous to register tenant ASAP to not miss any chatops requests.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(
|
||||
service_tenant_id,
|
||||
cluster_slug,
|
||||
SERVICE_TYPE_ONCALL,
|
||||
stack_id,
|
||||
)
|
||||
register_oncall_tenant(org)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"create_oncall_connector: failed "
|
||||
f"oncall_org_id={service_tenant_id} backend={cluster_slug} stack_id={stack_id} exc={e}"
|
||||
)
|
||||
logger.error(f"create_oncall_connector: failed organization_id={org} exc={e}")
|
||||
register_oncall_tenant_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_tenant_id": str(org.uuid),
|
||||
"cluster_slug": settings.ONCALL_BACKEND_REGION,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
"stack_id": stack_id,
|
||||
"stack_id": org.stack_id,
|
||||
"stack_slug": org.stack_slug,
|
||||
"org_id": org.id,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
|
@ -141,3 +135,23 @@ def unlink_slack_team(service_tenant_id: str, slack_team_id: str):
|
|||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def uninstall_slack(stack_id: int, grafana_user_id: int) -> bool:
|
||||
"""
|
||||
uninstall_slack uninstalls slack integration from chatops-proxy and returns bool indicating if it was removed.
|
||||
If such installation does not exist - returns True as well.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
removed, response = client.delete_oauth_installation(stack_id, PROVIDER_TYPE_SLACK, grafana_user_id)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 404:
|
||||
return True
|
||||
logger.exception(
|
||||
"uninstall_slack: error trying to install slack from chatops-proxy: " "error=%s",
|
||||
api_exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return removed
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ class PhoneBackend:
|
|||
|
||||
def _notify_connected_number(self, user):
|
||||
text = (
|
||||
f"This phone number has been connected to Grafana OnCall team"
|
||||
f"This phone number has been connected to Grafana OnCall team "
|
||||
f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3'
|
||||
)
|
||||
try:
|
||||
|
|
@ -392,7 +392,7 @@ class PhoneBackend:
|
|||
|
||||
def _notify_disconnected_number(self, user, number):
|
||||
text = (
|
||||
f"This phone number has been disconnected from Grafana OnCall team"
|
||||
f"This phone number has been disconnected from Grafana OnCall team "
|
||||
f'"{user.organization.stack_slug}"\nYour Grafana OnCall <3'
|
||||
)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ def test_list_filters(
|
|||
def assert_expected(response, expected):
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
returned = [s["id"] for s in response.json().get("results", [])]
|
||||
assert returned == [s.public_primary_key for s in expected]
|
||||
assert sorted(returned) == sorted([s.public_primary_key for s in expected])
|
||||
|
||||
client = APIClient()
|
||||
base_url = reverse("api-public:shift_swap-list")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from apps.user_management.models import User
|
|||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
|
||||
from common.api_helpers.paginators import FiftyPageSizePaginator
|
||||
from common.exceptions import UserNotificationPolicyCouldNotBeDeleted
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
|
||||
|
||||
|
|
@ -75,10 +74,7 @@ class PersonalNotificationView(RateLimitHeadersMixin, UpdateSerializerMixin, Mod
|
|||
def perform_destroy(self, instance):
|
||||
user = self.request.user
|
||||
prev_state = user.insight_logs_serialized
|
||||
try:
|
||||
instance.delete()
|
||||
except UserNotificationPolicyCouldNotBeDeleted:
|
||||
raise BadRequest(detail="Can't delete last user notification policy")
|
||||
instance.delete()
|
||||
new_state = user.insight_logs_serialized
|
||||
write_resource_insight_log(
|
||||
instance=user,
|
||||
|
|
|
|||
|
|
@ -706,6 +706,13 @@ def _create_user_option_groups(
|
|||
}
|
||||
)
|
||||
|
||||
# Only inject chatops-proxy metadata into the first dropdown option to reduce payload size
|
||||
# so the 250kb Slack limit is not exceeded for orgs with many users
|
||||
if option_groups:
|
||||
option_groups[0]["options"][0]["value"] = make_value(
|
||||
json.loads(option_groups[0]["options"][0]["value"]), organization
|
||||
)
|
||||
|
||||
return option_groups
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from common.api_helpers.optional_slash_router import optional_slash_path
|
||||
|
||||
from .views import (
|
||||
InstallLinkRedirectView,
|
||||
OAuthSlackView,
|
||||
|
|
@ -13,8 +15,8 @@ urlpatterns = [
|
|||
path("event_api_endpoint/", SlackEventApiEndpointView.as_view()),
|
||||
path("interactive_api_endpoint/", SlackEventApiEndpointView.as_view()),
|
||||
# New urls used in cloud via chatops-proxy v3.
|
||||
path("events/", SlackEventApiEndpointView.as_view()),
|
||||
path("interactive/", SlackEventApiEndpointView.as_view()),
|
||||
optional_slash_path("events", SlackEventApiEndpointView.as_view()),
|
||||
optional_slash_path("interactive", SlackEventApiEndpointView.as_view()),
|
||||
# Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it.
|
||||
path("reset_slack", ResetSlackView.as_view(), name="reset-slack"),
|
||||
# Deprecated.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from rest_framework.views import APIView
|
|||
from apps.api.permissions import RBACPermission
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.base.utils import live_settings
|
||||
from apps.chatops_proxy.utils import uninstall_slack as uninstall_slack_from_chatops_proxy
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.errors import SlackAPIError
|
||||
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
|
||||
|
|
@ -573,8 +574,19 @@ class ResetSlackView(APIView):
|
|||
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
|
||||
status=400,
|
||||
)
|
||||
if settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
# If unified slack app is enabled - uninstall slack integration from chatops-proxy first and on success -
|
||||
# uninstall it from OnCall.
|
||||
removed = uninstall_slack_from_chatops_proxy(request.user.organization.stack_id, request.user.user_id)
|
||||
else:
|
||||
# just a placeholder value to continute uninstallation until UNIFIED_SLACK_APP_ENABLED is not enabled
|
||||
removed = True
|
||||
if not removed:
|
||||
return Response({"error": "Failed to uninstall slack integration"}, status=500)
|
||||
|
||||
try:
|
||||
uninstall_slack_integration(request.user.organization, request.user)
|
||||
except SlackInstallationExc as e:
|
||||
return Response({"error": e.error_message}, status=400)
|
||||
|
||||
return Response(status=200)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ from django.utils import timezone
|
|||
from mirage import fields as mirage_fields
|
||||
|
||||
from apps.alerts.models import MaintainableObject
|
||||
from apps.chatops_proxy.utils import register_oncall_tenant, unlink_slack_team, unregister_oncall_tenant
|
||||
from apps.chatops_proxy.utils import (
|
||||
register_oncall_tenant_with_async_fallback,
|
||||
unlink_slack_team,
|
||||
unregister_oncall_tenant,
|
||||
)
|
||||
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
|
||||
from apps.user_management.types import AlertGroupTableColumn
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
|
|
@ -61,7 +65,7 @@ class OrganizationQuerySet(models.QuerySet):
|
|||
def create(self, **kwargs):
|
||||
instance = super().create(**kwargs)
|
||||
if settings.FEATURE_MULTIREGION_ENABLED:
|
||||
register_oncall_tenant(str(instance.uuid), settings.ONCALL_BACKEND_REGION, instance.stack_id)
|
||||
register_oncall_tenant_with_async_fallback(instance)
|
||||
return instance
|
||||
|
||||
def delete(self):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import pytz
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
|
@ -84,8 +84,6 @@ class UserManager(models.Manager["User"]):
|
|||
|
||||
@staticmethod
|
||||
def sync_for_organization(organization, api_users: list[dict]):
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
|
||||
grafana_users = {user["userId"]: user for user in api_users}
|
||||
existing_user_ids = set(organization.users.all().values_list("user_id", flat=True))
|
||||
|
||||
|
|
@ -105,21 +103,7 @@ class UserManager(models.Manager["User"]):
|
|||
if user["userId"] not in existing_user_ids
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
organization.users.bulk_create(users_to_create, batch_size=5000)
|
||||
# Retrieve primary keys for the newly created users
|
||||
#
|
||||
# If the model’s primary key is an AutoField, the primary key attribute can only be retrieved
|
||||
# on certain databases (currently PostgreSQL, MariaDB 10.5+, and SQLite 3.35+).
|
||||
# On other databases, it will not be set.
|
||||
# https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.query.QuerySet.bulk_create
|
||||
created_users = organization.users.exclude(user_id__in=existing_user_ids)
|
||||
|
||||
policies_to_create = ()
|
||||
for user in created_users:
|
||||
policies_to_create = policies_to_create + user.default_notification_policies_defaults
|
||||
policies_to_create = policies_to_create + user.important_notification_policies_defaults
|
||||
UserNotificationPolicy.objects.bulk_create(policies_to_create, batch_size=5000)
|
||||
organization.users.bulk_create(users_to_create, batch_size=5000)
|
||||
|
||||
# delete excess users
|
||||
user_ids_to_delete = existing_user_ids - grafana_users.keys()
|
||||
|
|
@ -429,43 +413,25 @@ class User(models.Model):
|
|||
return PermissionsQuery(permissions__contains=[required_permission])
|
||||
return RoleInQuery(role__lte=permission.fallback_role.value)
|
||||
|
||||
def get_or_create_notification_policies(self, important=False):
|
||||
def get_notification_policies_or_use_default_fallback(
|
||||
self, important=False
|
||||
) -> typing.List["UserNotificationPolicy"]:
|
||||
"""
|
||||
If the user has no notification policies defined, fallback to using e-mail as the notification channel.
|
||||
"""
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
|
||||
if not self.notification_policies.filter(important=important).exists():
|
||||
if important:
|
||||
self.notification_policies.create_important_policies_for_user(self)
|
||||
else:
|
||||
self.notification_policies.create_default_policies_for_user(self)
|
||||
notification_policies = self.notification_policies.filter(important=important)
|
||||
return notification_policies
|
||||
|
||||
@property
|
||||
def default_notification_policies_defaults(self):
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
|
||||
print(self)
|
||||
|
||||
return (
|
||||
UserNotificationPolicy(
|
||||
user=self,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=settings.EMAIL_BACKEND_INTERNAL_ID,
|
||||
order=0,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def important_notification_policies_defaults(self):
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
|
||||
return (
|
||||
UserNotificationPolicy(
|
||||
user=self,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=settings.EMAIL_BACKEND_INTERNAL_ID,
|
||||
important=True,
|
||||
order=0,
|
||||
),
|
||||
)
|
||||
return [
|
||||
UserNotificationPolicy(
|
||||
user=self,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=settings.EMAIL_BACKEND_INTERNAL_ID,
|
||||
important=important,
|
||||
order=0,
|
||||
),
|
||||
]
|
||||
return list(self.notification_policies.filter(important=important).all())
|
||||
|
||||
def update_alert_group_table_selected_columns(self, columns: typing.List[AlertGroupTableColumn]) -> None:
|
||||
if self.alert_group_table_selected_columns != columns:
|
||||
|
|
@ -498,9 +464,6 @@ class User(models.Model):
|
|||
# TODO: check whether this signal can be moved to save method of the model
|
||||
@receiver(post_save, sender=User)
|
||||
def listen_for_user_model_save(sender: User, instance: User, created: bool, *args, **kwargs) -> None:
|
||||
if created:
|
||||
instance.notification_policies.create_default_policies_for_user(instance)
|
||||
instance.notification_policies.create_important_policies_for_user(instance)
|
||||
drop_cached_ical_for_custom_events_for_organization.apply_async(
|
||||
(instance.organization_id,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -118,18 +118,6 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati
|
|||
assert created_user.name == api_users[1]["name"]
|
||||
assert created_user.avatar_full_url == "https://test.test/test/1234"
|
||||
|
||||
assert created_user.notification_policies.filter(important=False).count() == 1
|
||||
assert (
|
||||
created_user.notification_policies.filter(important=False).first().notify_by
|
||||
== settings.EMAIL_BACKEND_INTERNAL_ID
|
||||
)
|
||||
|
||||
assert created_user.notification_policies.filter(important=True).count() == 1
|
||||
assert (
|
||||
created_user.notification_policies.filter(important=True).first().notify_by
|
||||
== settings.EMAIL_BACKEND_INTERNAL_ID
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_users_for_organization_role_none(make_organization, make_user_for_organization):
|
||||
|
|
|
|||
|
|
@ -3,5 +3,4 @@ from .exceptions import ( # noqa: F401
|
|||
MaintenanceCouldNotBeStartedError,
|
||||
TeamCanNotBeChangedError,
|
||||
UnableToSendDemoAlert,
|
||||
UserNotificationPolicyCouldNotBeDeleted,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,10 +19,6 @@ class UnableToSendDemoAlert(OperationCouldNotBePerformedError):
|
|||
pass
|
||||
|
||||
|
||||
class UserNotificationPolicyCouldNotBeDeleted(OperationCouldNotBePerformedError):
|
||||
pass
|
||||
|
||||
|
||||
class BacksyncIntegrationRequestError(Exception):
|
||||
"""Error making request to alert receive channel backsync connection."""
|
||||
|
||||
|
|
|
|||
|
|
@ -590,6 +590,14 @@ CELERY_BEAT_SCHEDULE = {
|
|||
},
|
||||
}
|
||||
|
||||
START_SYNC_ORG_WITH_CHATOPS_PROXY_ENABLED = getenv_boolean("START_SYNC_ORG_WITH_CHATOPS_PROXY_ENABLED", default=False)
|
||||
if FEATURE_MULTIREGION_ENABLED and START_SYNC_ORG_WITH_CHATOPS_PROXY_ENABLED:
|
||||
CELERY_BEAT_SCHEDULE["start_sync_org_with_chatops_proxy"] = {
|
||||
"task": "apps.chatops_proxy.tasks.start_sync_org_with_chatops_proxy",
|
||||
"schedule": crontab(hour="*/24"), # Every 24 hours, feel free to adjust
|
||||
"args": (),
|
||||
}
|
||||
|
||||
if ESCALATION_AUDITOR_ENABLED:
|
||||
CELERY_BEAT_SCHEDULE["check_escalations"] = {
|
||||
"task": "apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.chatops_proxy.tasks.unlink_slack_team_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.register_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.unregister_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.start_sync_org_with_chatops_proxy": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.sync_org_with_chatops_proxy": {"queue": "default"},
|
||||
# CRITICAL
|
||||
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
|
||||
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup';
|
|||
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
|
@ -11,7 +11,7 @@ test('we can create an oncall schedule + receive an alert', async ({ adminRolePa
|
|||
const integrationName = generateRandomValue();
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
await createEscalationChain(
|
||||
page,
|
||||
escalationChainName,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { createEscalationChain, EscalationStep } from '../utils/escalationChain'
|
|||
import { clickButton, generateRandomValue } from '../utils/forms';
|
||||
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
|
||||
import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
/**
|
||||
* Insights is dependent on Scenes which were only added in Grafana 10.0.0
|
||||
|
|
@ -66,7 +66,7 @@ test.describe.skip('Insights', () => {
|
|||
const escalationChainName = generateRandomValue();
|
||||
const integrationName = generateRandomValue();
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
await createEscalationChain(
|
||||
page,
|
||||
escalationChainName,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import dayjs from 'dayjs';
|
||||
|
||||
import { test, expect } from '../fixtures';
|
||||
import { test, expect, Locator } from '../fixtures';
|
||||
import { MOSCOW_TIMEZONE } from '../utils/constants';
|
||||
import { clickButton, generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallScheduleWithRotation, getOverrideFormDateInputs } from '../utils/schedule';
|
||||
import { setTimezoneInProfile } from '../utils/grafanaProfile';
|
||||
import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule';
|
||||
|
||||
test('default dates in override creation modal are correct', async ({ adminRolePage }) => {
|
||||
test('Default dates in override creation modal are set to today', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
await clickButton({ page, buttonText: 'Add override' });
|
||||
|
||||
|
|
@ -20,3 +22,39 @@ test('default dates in override creation modal are correct', async ({ adminRoleP
|
|||
expect(overrideFormDateInputs.start.isSame(expectedStart)).toBe(true);
|
||||
expect(overrideFormDateInputs.end.isSame(expectedEnd)).toBe(true);
|
||||
});
|
||||
|
||||
test('Fills in override time and reacts to timezone change', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
await setTimezoneInProfile(page, MOSCOW_TIMEZONE); // UTC+3
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName, false);
|
||||
|
||||
await clickButton({ page, buttonText: 'Add override' });
|
||||
|
||||
const overrideStartEl = page.getByTestId('override-start');
|
||||
await changeDatePickerTime(overrideStartEl, '02');
|
||||
await expect(overrideStartEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('02:00');
|
||||
|
||||
const overrideEndEl = page.getByTestId('override-end');
|
||||
await changeDatePickerTime(overrideEndEl, '12');
|
||||
await expect(overrideEndEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('12:00');
|
||||
|
||||
await page.getByRole('dialog').click(); // clear focus
|
||||
|
||||
await page.getByTestId('timezone-select').locator('svg').click();
|
||||
await page.getByText('GMT', { exact: true }).click();
|
||||
|
||||
// expect times to go back by -3
|
||||
await expect(overrideStartEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('23:00');
|
||||
await expect(overrideEndEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('09:00');
|
||||
|
||||
async function changeDatePickerTime(element: Locator, value: string) {
|
||||
await element.getByRole('img').click();
|
||||
// set minutes to {value}
|
||||
await page.locator('.rc-time-picker-panel').getByRole('button', { name: value }).first().click();
|
||||
// set seconds to 00
|
||||
await page.getByRole('button', { name: '00' }).nth(1).click();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
43
grafana-plugin/e2e-tests/schedules/addRotation.test.ts
Normal file
43
grafana-plugin/e2e-tests/schedules/addRotation.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { test, expect, Locator } from '../fixtures';
|
||||
import { MOSCOW_TIMEZONE } from '../utils/constants';
|
||||
import { clickButton, generateRandomValue } from '../utils/forms';
|
||||
import { setTimezoneInProfile } from '../utils/grafanaProfile';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('Fills in Rotation time and reacts to timezone change', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
await setTimezoneInProfile(page, MOSCOW_TIMEZONE); // UTC+3
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName, false);
|
||||
|
||||
await clickButton({ page, buttonText: 'Add rotation' });
|
||||
// enable Rotation End
|
||||
await page.getByTestId('rotation-end').getByLabel('Toggle switch').click();
|
||||
|
||||
const startEl = page.getByTestId('rotation-start');
|
||||
await changeDatePickerTime(startEl, '02');
|
||||
await expect(startEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('02:00');
|
||||
|
||||
const endEl = page.getByTestId('rotation-end');
|
||||
await changeDatePickerTime(endEl, '12');
|
||||
await expect(endEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('12:00');
|
||||
|
||||
await page.getByRole('dialog').click(); // clear focus
|
||||
|
||||
await page.getByTestId('timezone-select').locator('svg').click();
|
||||
await page.getByText('GMT', { exact: true }).click();
|
||||
|
||||
// expect times to go back by -3
|
||||
await expect(startEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('23:00');
|
||||
await expect(endEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('09:00');
|
||||
|
||||
async function changeDatePickerTime(element: Locator, value: string) {
|
||||
await element.getByRole('img').click();
|
||||
// set minutes to {value}
|
||||
await page.locator('.rc-time-picker-panel').getByRole('button', { name: value }).first().click();
|
||||
// set seconds to 00
|
||||
await page.getByRole('button', { name: '00' }).nth(1).click();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { test, expect } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
const scheduleQualityElement = page.getByTestId('schedule-quality');
|
||||
await scheduleQualityElement.waitFor({ state: 'visible' });
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { test, expect } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallScheduleWithRotation, createRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule, createRotation } from '../utils/schedule';
|
||||
|
||||
test(`user can see the other user's details`, async ({ adminRolePage, editorRolePage }) => {
|
||||
const { page, userName: adminUserName } = adminRolePage;
|
||||
const editorUserName = editorRolePage.userName;
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, adminUserName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, adminUserName);
|
||||
await createRotation(page, editorUserName, false);
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { HTML_ID } from 'utils/DOM';
|
|||
|
||||
import { expect, test } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test.skip('schedule view (week/2 weeks/month) toggler works', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
// ScheduleView.OneWeek is selected by default
|
||||
expect(await page.getByLabel(ScheduleView.OneWeek, { exact: true }).isChecked()).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { expect, test } from '../fixtures';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { goToOnCallPage } from '../utils/navigation';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test('schedule calendar and list of schedules is correctly displayed', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
await goToOnCallPage(page, 'schedules');
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,14 @@ import isoWeek from 'dayjs/plugin/isoWeek';
|
|||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
import { test } from '../fixtures';
|
||||
import { MOSCOW_TIMEZONE } from '../utils/constants';
|
||||
import { clickButton, generateRandomValue } from '../utils/forms';
|
||||
import { setTimezoneInProfile } from '../utils/grafanaProfile';
|
||||
import { createOnCallScheduleWithRotation } from '../utils/schedule';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const MOSCOW_TIMEZONE = 'Europe/Moscow';
|
||||
|
||||
test.use({ timezoneId: MOSCOW_TIMEZONE }); // GMT+3 the whole year
|
||||
const currentUtcTimeHour = dayjs().utc().format('HH');
|
||||
const currentUtcDate = dayjs().utc().format('DD MMM');
|
||||
|
|
@ -25,7 +24,7 @@ test('dates in schedule are correct according to selected current timezone', asy
|
|||
await setTimezoneInProfile(page, MOSCOW_TIMEZONE);
|
||||
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallScheduleWithRotation(page, onCallScheduleName, userName);
|
||||
await createOnCallSchedule(page, onCallScheduleName, userName);
|
||||
|
||||
// Current timezone is selected by default to currently logged in user timezone
|
||||
await expect(page.getByTestId('timezone-select')).toHaveText('GMT+3');
|
||||
|
|
|
|||
|
|
@ -10,3 +10,5 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc
|
|||
|
||||
export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
|
||||
export const IS_CLOUD = !IS_OPEN_SOURCE;
|
||||
|
||||
export const MOSCOW_TIMEZONE = 'Europe/Moscow';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { Page } from '@playwright/test';
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
import { goToGrafanaPage } from './navigation';
|
||||
|
||||
export const setTimezoneInProfile = async (page: Page, timezone: string) => {
|
||||
await goToGrafanaPage(page, '/profile');
|
||||
await expect(page.getByLabel('Time zone picker')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Time zone picker').click();
|
||||
await page.getByLabel('Select options menu').getByText(timezone).click();
|
||||
await page.getByTestId('data-testid-shared-prefs-save').click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(3000); // wait for reload
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import dayjs from 'dayjs';
|
|||
import { clickButton, selectDropdownValue } from './forms';
|
||||
import { goToOnCallPage } from './navigation';
|
||||
|
||||
export const createOnCallScheduleWithRotation = async (
|
||||
export const createOnCallSchedule = async (
|
||||
page: Page,
|
||||
scheduleName: string,
|
||||
userName: string
|
||||
userName: string,
|
||||
withRotation = true
|
||||
): Promise<void> => {
|
||||
// go to the schedules page
|
||||
await goToOnCallPage(page, 'schedules');
|
||||
|
|
@ -22,7 +23,9 @@ export const createOnCallScheduleWithRotation = async (
|
|||
// Add a new layer w/ the current user to it
|
||||
await clickButton({ page, buttonText: 'Create Schedule' });
|
||||
|
||||
await createRotation(page, userName);
|
||||
if (withRotation) {
|
||||
await createRotation(page, userName);
|
||||
}
|
||||
};
|
||||
|
||||
export const createRotation = async (page: Page, userName: string, isFirstScheduleRotation = true) => {
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@
|
|||
"dayjs": "^1.11.5",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"linkify-react": "^4.1.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"mobx": "6.12.0",
|
||||
"mobx-react": "9.1.0",
|
||||
"object-hash": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,16 @@ export const getTextStyles = (theme: GrafanaTheme2) => {
|
|||
display: none;
|
||||
`,
|
||||
|
||||
withBackground: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: ${theme.spacing(0, 1)};
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
height: ${theme.spacing(theme.components.height.md)};
|
||||
`,
|
||||
|
||||
maxWidth: css`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
|
|||
maxWidth?: string;
|
||||
clickable?: boolean;
|
||||
customTag?: 'h6' | 'span';
|
||||
withBackground?: boolean;
|
||||
}
|
||||
|
||||
interface TextInterface extends React.FC<TextProps> {
|
||||
|
|
@ -51,6 +52,7 @@ export const Text: TextInterface = (props) => {
|
|||
clearBeforeEdit = false,
|
||||
hidden = false,
|
||||
editModalTitle = 'New value',
|
||||
withBackground = false,
|
||||
style,
|
||||
maxWidth,
|
||||
clickable,
|
||||
|
|
@ -97,6 +99,7 @@ export const Text: TextInterface = (props) => {
|
|||
{ [bem(styles.text, `underline`)]: underline },
|
||||
{ [bem(styles.text, 'clickable')]: clickable },
|
||||
{ [styles.noWrap]: !wrap },
|
||||
{ [styles.withBackground]: withBackground },
|
||||
className
|
||||
)}
|
||||
style={{ ...style, maxWidth }}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { parseUrl } from 'query-string';
|
||||
import { Controller, useForm, useFormContext, FormProvider } from 'react-hook-form';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
|
@ -40,6 +39,7 @@ import { useStore } from 'state/useStore';
|
|||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { PLUGIN_ROOT, generateAssignToTeamInputDescription, DOCS_ROOT, INTEGRATION_SERVICENOW } from 'utils/consts';
|
||||
import { useIsLoading } from 'utils/hooks';
|
||||
import { validateURL } from 'utils/string';
|
||||
import { OmitReadonlyMembers } from 'utils/types';
|
||||
|
||||
import { prepareForEdit } from './IntegrationForm.helpers';
|
||||
|
|
@ -406,10 +406,6 @@ export const IntegrationForm = observer(
|
|||
);
|
||||
}
|
||||
|
||||
function validateURL(urlFieldValue: string): string | boolean {
|
||||
return !parseUrl(urlFieldValue) ? 'Instance URL is invalid' : true;
|
||||
}
|
||||
|
||||
async function onFormSubmit(formData: IntegrationFormFields): Promise<void> {
|
||||
const labels = labelsRef.current?.getValue();
|
||||
|
||||
|
|
@ -648,7 +644,7 @@ const GrafanaContactPoint = observer(
|
|||
// filter contact points for current alert manager
|
||||
const contactPointsForCurrentOption = allContactPoints
|
||||
.find((opt) => opt.uid === option.value)
|
||||
.contact_points?.map((cp) => ({ value: cp, label: cp }));
|
||||
?.contact_points?.map((cp) => ({ value: cp, label: cp }));
|
||||
|
||||
const newState: Partial<GrafanaContactPointState> = {
|
||||
selectedAlertManagerOption: option.value,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.root .filter-options {
|
||||
|
|
@ -27,3 +27,15 @@
|
|||
min-width: 250px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid red;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid red;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { KeyValue, SelectableValue, TimeRange } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, KeyValue, SelectableValue, TimeRange } from '@grafana/data';
|
||||
import {
|
||||
InlineSwitch,
|
||||
MultiSelect,
|
||||
|
|
@ -11,9 +12,10 @@ import {
|
|||
Icon,
|
||||
Tooltip,
|
||||
Button,
|
||||
withTheme2,
|
||||
Themeable2,
|
||||
} from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce, isUndefined, omitBy, pickBy } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
|
|
@ -35,11 +37,7 @@ import { allFieldsEmpty } from 'utils/utils';
|
|||
import { parseFilters } from './RemoteFilters.helpers';
|
||||
import { FilterOption } from './RemoteFilters.types';
|
||||
|
||||
import styles from './RemoteFilters.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface RemoteFiltersProps extends WithStoreProps {
|
||||
interface RemoteFiltersProps extends WithStoreProps, Themeable2 {
|
||||
onChange: (filters: Record<string, any>, isOnMount: boolean, invalidateFn: () => boolean) => void;
|
||||
query: KeyValue;
|
||||
page: PAGE;
|
||||
|
|
@ -112,21 +110,21 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { extraFilters } = this.props;
|
||||
const { extraFilters, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={styles.root}>
|
||||
{this.renderFilters()}
|
||||
{extraFilters && (
|
||||
<div className={cx('extra-filters')}>
|
||||
{extraFilters(this.state, this.setState.bind(this), this.onFiltersValueChange.bind(this))}
|
||||
</div>
|
||||
<div>{extraFilters(this.state, this.setState.bind(this), this.onFiltersValueChange.bind(this))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilters = () => {
|
||||
const { theme } = this.props;
|
||||
const { filters, filterOptions } = this.state;
|
||||
|
||||
if (!filterOptions) {
|
||||
|
|
@ -145,20 +143,25 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
}));
|
||||
|
||||
const allowFreeSearch = filterOptions.some((filter: FilterOption) => filter.name === 'search');
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('filters')}>
|
||||
<div className={styles.filters}>
|
||||
{filters.map((filterOption: FilterOption) => (
|
||||
<div key={filterOption.name} className={cx('filter')}>
|
||||
<Text type="secondary">{filterOption.display_name || capitalCase(filterOption.name)}</Text>
|
||||
{filterOption.description && (
|
||||
<Tooltip content={filterOption.description}>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text type="secondary">:</Text> {this.renderFilterOption(filterOption)}
|
||||
<div key={filterOption.name} className={styles.filter}>
|
||||
<Text withBackground wrap={false} type="primary">
|
||||
{filterOption.display_name || capitalCase(filterOption.name)}
|
||||
{filterOption.description && (
|
||||
<span className={styles.infoIcon}>
|
||||
<Tooltip content={filterOption.description}>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{this.renderFilterOption(filterOption)}
|
||||
<Button
|
||||
size="sm"
|
||||
size="md"
|
||||
icon="times"
|
||||
tooltip="Remove filter"
|
||||
variant="secondary"
|
||||
|
|
@ -166,19 +169,20 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
/>
|
||||
</div>
|
||||
))}
|
||||
<Select
|
||||
menuShouldPortal
|
||||
key={filters.length}
|
||||
className={cx('filter-options')}
|
||||
placeholder="Search or filter results..."
|
||||
value={undefined}
|
||||
onChange={this.handleAddFilter}
|
||||
getOptionLabel={(item: SelectableValue) => capitalCase(item.label)}
|
||||
options={options}
|
||||
allowCustomValue={allowFreeSearch}
|
||||
onCreateOption={this.handleSearch}
|
||||
formatCreateLabel={(str) => `Search ${str}`}
|
||||
/>
|
||||
<div className={styles.filterOptions}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
key={filters.length}
|
||||
placeholder="Search or filter results..."
|
||||
value={undefined}
|
||||
onChange={this.handleAddFilter}
|
||||
getOptionLabel={(item: SelectableValue) => capitalCase(item.label)}
|
||||
options={options}
|
||||
allowCustomValue={allowFreeSearch}
|
||||
onCreateOption={this.handleSearch}
|
||||
formatCreateLabel={(str) => `Search ${str}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -243,7 +247,8 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
|
||||
renderFilterOption = (filter: FilterOption) => {
|
||||
const { values, hadInteraction } = this.state;
|
||||
const { grafanaTeamStore } = this.props;
|
||||
const { grafanaTeamStore, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const autoFocus = Boolean(hadInteraction);
|
||||
switch (filter.type) {
|
||||
|
|
@ -253,7 +258,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
<MultiSelect
|
||||
autoFocus={autoFocus}
|
||||
openMenuOnFocus
|
||||
className={cx('filter-select')}
|
||||
className={styles.filterSelect}
|
||||
options={filter.options.map((option: SelectOption) => ({
|
||||
label: option.display_name,
|
||||
value: option.value,
|
||||
|
|
@ -267,7 +272,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
return (
|
||||
<RemoteSelect
|
||||
autoFocus={autoFocus}
|
||||
className={cx('filter-select')}
|
||||
className={styles.filterSelect}
|
||||
isMulti
|
||||
fieldToShow="display_name"
|
||||
valueField="value"
|
||||
|
|
@ -286,6 +291,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
transparent
|
||||
value={values[filter.name]}
|
||||
onChange={this.getBooleanFilterChangeHandler(filter.name)}
|
||||
className={styles.border}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -303,7 +309,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
return (
|
||||
<RemoteSelect
|
||||
autoFocus={autoFocus}
|
||||
className={cx('filter-select')}
|
||||
className={styles.filterSelect}
|
||||
isMulti
|
||||
fieldToShow="name"
|
||||
valueField="id"
|
||||
|
|
@ -333,7 +339,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
<LabelsFilter
|
||||
filterType={filter.type}
|
||||
autoFocus={autoFocus}
|
||||
className={cx('filter-select')}
|
||||
className={styles.filterSelect}
|
||||
value={values[filter.name]}
|
||||
onChange={this.getLabelsFilterChangeHandler(filter.name)}
|
||||
/>
|
||||
|
|
@ -444,6 +450,52 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
debouncedOnChange = debounce(this.onChange, 500);
|
||||
}
|
||||
|
||||
export const RemoteFilters = withMobXProviderContext(_RemoteFilters) as unknown as React.ComponentClass<
|
||||
Omit<RemoteFiltersProps, 'store'>
|
||||
export const RemoteFilters = withMobXProviderContext(withTheme2(_RemoteFilters)) as unknown as React.ComponentClass<
|
||||
Omit<RemoteFiltersProps, 'store' | 'theme'>
|
||||
>;
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
root: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`,
|
||||
|
||||
filters: css`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid ${theme.colors.border.weak}
|
||||
border-radius: 2px;
|
||||
flex-wrap: wrap;
|
||||
`,
|
||||
|
||||
filter: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
`,
|
||||
|
||||
filterOptions: css`
|
||||
width: 250px;
|
||||
`,
|
||||
|
||||
filterSelect: css`
|
||||
min-width: 250px;
|
||||
width: fit-content;
|
||||
`,
|
||||
|
||||
infoIcon: css`
|
||||
margin-left: 4px;
|
||||
`,
|
||||
|
||||
border: css`
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
|
||||
&:hover {
|
||||
border: 1px solid ${theme.colors.border.strong};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { Dayjs, ManipulateType } from 'dayjs';
|
||||
import { DraggableData } from 'react-draggable';
|
||||
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { GRAFANA_HEADER_HEIGHT, GRAFANA_LEGACY_SIDEBAR_WIDTH } from 'utils/consts';
|
||||
|
||||
import { RepeatEveryPeriod } from './RotationForm.types';
|
||||
|
||||
|
|
@ -173,3 +177,40 @@ export const dayJSAddWithDSTFixed = ({
|
|||
|
||||
return newDateCandidate.add(diff, 'minutes');
|
||||
};
|
||||
|
||||
export function getDraggableModalCoordinatesOnInit(
|
||||
data: DraggableData,
|
||||
offsetTop: number
|
||||
): {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
} {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollBarReferenceElements = document.querySelectorAll<HTMLElement>('.scrollbar-view');
|
||||
// top navbar display has 2 scrollbar-view elements (navbar & content)
|
||||
const baseReferenceElRect = (
|
||||
scrollBarReferenceElements.length === 1 ? scrollBarReferenceElements[0] : scrollBarReferenceElements[1]
|
||||
).getBoundingClientRect();
|
||||
|
||||
const { right, bottom } = baseReferenceElRect;
|
||||
|
||||
return isTopNavbar()
|
||||
? {
|
||||
// values are adjusted by any padding/margin differences
|
||||
left: -data.node.offsetLeft + 4,
|
||||
right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12,
|
||||
top: -offsetTop + GRAFANA_HEADER_HEIGHT + 4,
|
||||
bottom: bottom - data.node.offsetHeight - offsetTop - 12,
|
||||
}
|
||||
: {
|
||||
left: -data.node.offsetLeft + 4 + GRAFANA_LEGACY_SIDEBAR_WIDTH,
|
||||
right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12,
|
||||
top: -offsetTop + 4,
|
||||
bottom: bottom - data.node.offsetHeight - offsetTop - 12,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
TimeUnit,
|
||||
timeUnitsToSeconds,
|
||||
TIME_UNITS_ORDER,
|
||||
getDraggableModalCoordinatesOnInit,
|
||||
} from 'containers/RotationForm/RotationForm.helpers';
|
||||
import { RepeatEveryPeriod } from 'containers/RotationForm/RotationForm.types';
|
||||
import { DateTimePicker } from 'containers/RotationForm/parts/DateTimePicker';
|
||||
|
|
@ -47,6 +48,7 @@ import { DaysSelector } from 'containers/RotationForm/parts/DaysSelector';
|
|||
import { DeletionModal } from 'containers/RotationForm/parts/DeletionModal';
|
||||
import { TimeUnitSelector } from 'containers/RotationForm/parts/TimeUnitSelector';
|
||||
import { UserItem } from 'containers/RotationForm/parts/UserItem';
|
||||
import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers';
|
||||
import { getShiftName } from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, Shift } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
|
@ -58,12 +60,11 @@ import {
|
|||
getUTCWeekStart,
|
||||
getWeekStartString,
|
||||
toDateWithTimezoneOffset,
|
||||
toDateWithTimezoneOffsetAtMidnight,
|
||||
} from 'pages/schedule/Schedule.helpers';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getCoords, waitForElement } from 'utils/DOM';
|
||||
import { GRAFANA_HEADER_HEIGHT, GRAFANA_LEGACY_SIDEBAR_WIDTH } from 'utils/consts';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
import { GRAFANA_HEADER_HEIGHT } from 'utils/consts';
|
||||
import { useDebouncedCallback, useResize } from 'utils/hooks';
|
||||
|
||||
import styles from './RotationForm.module.css';
|
||||
|
||||
|
|
@ -85,17 +86,11 @@ interface RotationFormProps {
|
|||
|
||||
const getStartShift = (start: dayjs.Dayjs, timezoneOffset: number, isNewRotation = false) => {
|
||||
if (isNewRotation) {
|
||||
// all new rotations default to midnight in selected timezone offset
|
||||
return toDateWithTimezoneOffset(start, timezoneOffset)
|
||||
.set('date', 1)
|
||||
.set('year', start.year())
|
||||
.set('month', start.month())
|
||||
.set('date', start.date())
|
||||
.set('hour', 0)
|
||||
.set('minute', 0)
|
||||
.set('second', 0);
|
||||
// default to midnight for new rotations
|
||||
return toDateWithTimezoneOffsetAtMidnight(start, timezoneOffset);
|
||||
}
|
||||
|
||||
// not always midnight
|
||||
return toDateWithTimezoneOffset(start, timezoneOffset);
|
||||
};
|
||||
|
||||
|
|
@ -123,9 +118,9 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
undefined
|
||||
);
|
||||
|
||||
const [rotationName, setRotationName] = useState<string>(`[L${layerPriority}] Rotation`);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [offsetTop, setOffsetTop] = useState<number>(GRAFANA_HEADER_HEIGHT + 10);
|
||||
const [rotationName, setRotationName] = useState(`[L${layerPriority}] Rotation`);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [offsetTop, setOffsetTop] = useState(GRAFANA_HEADER_HEIGHT + 10);
|
||||
const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined);
|
||||
|
||||
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(
|
||||
|
|
@ -136,32 +131,27 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
propsShiftEnd?.utcOffset(store.timezoneStore.selectedTimezoneOffset) || shiftStart.add(1, 'day')
|
||||
);
|
||||
|
||||
const [activePeriod, setActivePeriod] = useState<number | undefined>(undefined);
|
||||
const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState<number | undefined>(undefined);
|
||||
const [activePeriod, setActivePeriod] = useState<number>(undefined);
|
||||
const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState<number>(undefined);
|
||||
|
||||
const [rotationStart, setRotationStart] = useState<dayjs.Dayjs>(shiftStart);
|
||||
const [endLess, setEndless] = useState<boolean>(shift?.until === undefined ? true : !Boolean(shift.until));
|
||||
const [rotationEnd, setRotationEnd] = useState<dayjs.Dayjs>(shiftStart.add(1, 'month'));
|
||||
|
||||
const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
|
||||
const [repeatEveryPeriod, setRepeatEveryPeriod] = useState<RepeatEveryPeriod>(RepeatEveryPeriod.DAYS);
|
||||
const [recurrenceNum, setRecurrenceNum] = useState(1);
|
||||
const [recurrencePeriod, setRecurrencePeriod] = useState(RepeatEveryPeriod.DAYS);
|
||||
|
||||
const [showActiveOnSelectedDays, setShowActiveOnSelectedDays] = useState<boolean>(false);
|
||||
const [showActiveOnSelectedPartOfDay, setShowActiveOnSelectedPartOfDay] = useState<boolean>(false);
|
||||
const [isMaskedByWeekdays, setIsMaskedByWeekdays] = useState(false);
|
||||
const [isLimitShiftEnabled, setIsLimitShiftEnabled] = useState(false);
|
||||
|
||||
const [selectedDays, setSelectedDays] = useState<string[]>([]);
|
||||
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
|
||||
const [showDeleteRotationConfirmation, setShowDeleteRotationConfirmation] = useState<boolean>(false);
|
||||
const [showDeleteRotationConfirmation, setShowDeleteRotationConfirmation] = useState(false);
|
||||
const debouncedOnResize = useDebouncedCallback(onResize, 250);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', debouncedOnResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedOnResize);
|
||||
};
|
||||
}, []);
|
||||
useResize(debouncedOnResize);
|
||||
|
||||
useEffect(() => {
|
||||
if (rotationStart.isBefore(shiftStart)) {
|
||||
|
|
@ -170,15 +160,15 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
}, [rotationStart, shiftStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActiveOnSelectedDays) {
|
||||
if (!isMaskedByWeekdays) {
|
||||
setSelectedDays([]);
|
||||
}
|
||||
}, [showActiveOnSelectedDays]);
|
||||
}, [isMaskedByWeekdays]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (isOpen) {
|
||||
setOffsetTop(await calculateOffsetTop());
|
||||
setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`));
|
||||
}
|
||||
})();
|
||||
}, [isOpen]);
|
||||
|
|
@ -243,8 +233,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
shift_start: getUTCString(shiftStart),
|
||||
shift_end: getUTCString(shiftEnd),
|
||||
rolling_users: userGroups,
|
||||
interval: repeatEveryValue,
|
||||
frequency: repeatEveryPeriod,
|
||||
interval: recurrenceNum,
|
||||
frequency: recurrencePeriod,
|
||||
by_day: getUTCByDay({
|
||||
dayOptions: store.scheduleStore.byDayOptions,
|
||||
by_day: selectedDays,
|
||||
|
|
@ -263,8 +253,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
shiftStart,
|
||||
shiftEnd,
|
||||
userGroups,
|
||||
repeatEveryValue,
|
||||
repeatEveryPeriod,
|
||||
recurrenceNum,
|
||||
recurrencePeriod,
|
||||
selectedDays,
|
||||
shiftId,
|
||||
layerPriority,
|
||||
|
|
@ -308,14 +298,17 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
onShowRotationForm(shift.updated_shift);
|
||||
}, [shift?.updated_shift]);
|
||||
|
||||
const handleRepeatEveryPeriodChange = useCallback(
|
||||
const onRecurrencePeriodChange = useCallback(
|
||||
(value) => {
|
||||
setShiftPeriodDefaultValue(undefined);
|
||||
setRecurrencePeriod(value);
|
||||
|
||||
setRepeatEveryPeriod(value);
|
||||
if (value === RepeatEveryPeriod.MONTHS && !isMaskedByWeekdays) {
|
||||
setIsLimitShiftEnabled(false);
|
||||
}
|
||||
|
||||
if (!showActiveOnSelectedPartOfDay) {
|
||||
if (showActiveOnSelectedDays) {
|
||||
if (!isLimitShiftEnabled) {
|
||||
if (isMaskedByWeekdays) {
|
||||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: shiftStart,
|
||||
|
|
@ -326,13 +319,13 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: shiftStart,
|
||||
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[value]],
|
||||
addParams: [recurrenceNum, repeatEveryPeriodToUnitName[value]],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[showActiveOnSelectedPartOfDay, showActiveOnSelectedDays, repeatEveryValue, shiftStart]
|
||||
[isLimitShiftEnabled, isMaskedByWeekdays, recurrenceNum, shiftStart]
|
||||
);
|
||||
|
||||
const handleRepeatEveryValueChange = (option) => {
|
||||
|
|
@ -342,13 +335,13 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
}
|
||||
|
||||
setShiftPeriodDefaultValue(undefined);
|
||||
setRepeatEveryValue(value);
|
||||
setRecurrenceNum(value);
|
||||
|
||||
if (!showActiveOnSelectedPartOfDay) {
|
||||
if (!isLimitShiftEnabled) {
|
||||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: rotationStart,
|
||||
addParams: [value, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
|
||||
addParams: [value, repeatEveryPeriodToUnitName[recurrencePeriod]],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -359,14 +352,14 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
setShiftStart(value);
|
||||
|
||||
setShiftEnd(
|
||||
showActiveOnSelectedPartOfDay
|
||||
isLimitShiftEnabled
|
||||
? dayJSAddWithDSTFixed({
|
||||
baseDate: value,
|
||||
addParams: [activePeriod, 'seconds'],
|
||||
})
|
||||
: dayJSAddWithDSTFixed({
|
||||
baseDate: value,
|
||||
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
|
||||
addParams: [recurrenceNum, repeatEveryPeriodToUnitName[recurrencePeriod]],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -391,11 +384,16 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
[shiftId, params, shift]
|
||||
);
|
||||
|
||||
const handleShowActiveOnSelectedDaysToggle = useCallback(
|
||||
const onMaskedByWeekdaysSwitch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
const disableLimitShift = !value && recurrencePeriod === RepeatEveryPeriod.MONTHS;
|
||||
|
||||
setShowActiveOnSelectedDays(value);
|
||||
setIsMaskedByWeekdays(value);
|
||||
|
||||
if (disableLimitShift) {
|
||||
setIsLimitShiftEnabled(false);
|
||||
}
|
||||
|
||||
if (value && shiftEnd.diff(shiftStart, 'hours') > 24) {
|
||||
setShiftEnd(
|
||||
|
|
@ -405,26 +403,26 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
})
|
||||
);
|
||||
} else {
|
||||
if (!showActiveOnSelectedPartOfDay) {
|
||||
if (!isLimitShiftEnabled || disableLimitShift) {
|
||||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: shiftStart,
|
||||
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
|
||||
addParams: [recurrenceNum, repeatEveryPeriodToUnitName[recurrencePeriod]],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[showActiveOnSelectedPartOfDay, shiftStart, shiftEnd, repeatEveryValue, repeatEveryPeriod]
|
||||
[isLimitShiftEnabled, shiftStart, shiftEnd, recurrenceNum, recurrencePeriod]
|
||||
);
|
||||
|
||||
const handleShowActiveOnSelectedPartOfDayToggle = useCallback(
|
||||
const onLimitShiftSwitch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
setShowActiveOnSelectedPartOfDay(value);
|
||||
setIsLimitShiftEnabled(value);
|
||||
|
||||
if (!value) {
|
||||
if (showActiveOnSelectedDays && shiftEnd.diff(shiftStart, 'hours') > 24) {
|
||||
if (isMaskedByWeekdays && shiftEnd.diff(shiftStart, 'hours') > 24) {
|
||||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: shiftStart,
|
||||
|
|
@ -435,21 +433,15 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
setShiftEnd(
|
||||
dayJSAddWithDSTFixed({
|
||||
baseDate: shiftStart,
|
||||
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
|
||||
addParams: [recurrenceNum, repeatEveryPeriodToUnitName[recurrencePeriod]],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[shiftStart, shiftEnd, repeatEveryPeriod, repeatEveryValue, showActiveOnSelectedDays]
|
||||
[shiftStart, shiftEnd, recurrencePeriod, recurrenceNum, isMaskedByWeekdays]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (repeatEveryPeriod === RepeatEveryPeriod.MONTHS) {
|
||||
setShowActiveOnSelectedPartOfDay(false);
|
||||
}
|
||||
}, [repeatEveryPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shift) {
|
||||
setRotationName(getShiftName(shift));
|
||||
|
|
@ -471,8 +463,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
const shiftEnd = toDateWithTimezoneOffset(dayjs(shift.shift_end), store.timezoneStore.selectedTimezoneOffset);
|
||||
setShiftEnd(shiftEnd);
|
||||
|
||||
setRepeatEveryValue(shift.interval);
|
||||
setRepeatEveryPeriod(shift.frequency);
|
||||
setRecurrenceNum(shift.interval);
|
||||
setRecurrencePeriod(shift.frequency);
|
||||
setSelectedDays(
|
||||
getSelectedDays({
|
||||
dayOptions: store.scheduleStore.byDayOptions,
|
||||
|
|
@ -481,13 +473,15 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
})
|
||||
);
|
||||
|
||||
setShowActiveOnSelectedDays(Boolean(shift.by_day?.length));
|
||||
setIsMaskedByWeekdays(Boolean(shift.by_day?.length));
|
||||
|
||||
const isMonthlyRecurrence = shift.frequency === RepeatEveryPeriod.MONTHS;
|
||||
const activeOnSelectedPartOfDay =
|
||||
shift.frequency !== RepeatEveryPeriod.MONTHS &&
|
||||
repeatEveryInSeconds(shift.frequency, shift.interval) !== shiftEnd.diff(shiftStart, 'seconds');
|
||||
repeatEveryInSeconds(shift.frequency, shift.interval) !== shiftEnd.diff(shiftStart, 'seconds') &&
|
||||
// Disallow for Monthly view, except if it's masked by week days
|
||||
(!isMonthlyRecurrence || (isMonthlyRecurrence && isMaskedByWeekdays));
|
||||
|
||||
setShowActiveOnSelectedPartOfDay(activeOnSelectedPartOfDay);
|
||||
setIsLimitShiftEnabled(activeOnSelectedPartOfDay);
|
||||
if (activeOnSelectedPartOfDay) {
|
||||
const activePeriod = shiftEnd.diff(shiftStart, 'seconds');
|
||||
|
||||
|
|
@ -612,6 +606,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
Starts
|
||||
</Text>
|
||||
}
|
||||
data-testid="rotation-start"
|
||||
>
|
||||
<DateTimePicker
|
||||
value={rotationStart}
|
||||
|
|
@ -636,6 +631,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
/>
|
||||
</HorizontalGroup>
|
||||
}
|
||||
data-testid="rotation-end"
|
||||
>
|
||||
{endLess ? (
|
||||
<div style={{ lineHeight: '32px' }}>
|
||||
|
|
@ -669,8 +665,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
>
|
||||
<Select
|
||||
maxMenuHeight={120}
|
||||
value={repeatEveryValue}
|
||||
options={getRepeatShiftsEveryOptions(repeatEveryPeriod)}
|
||||
value={recurrenceNum}
|
||||
options={getRepeatShiftsEveryOptions(recurrencePeriod)}
|
||||
onChange={handleRepeatEveryValueChange}
|
||||
disabled={disabled}
|
||||
allowCustomValue
|
||||
|
|
@ -680,8 +676,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
<RemoteSelect
|
||||
showSearch={false}
|
||||
href="/oncall_shifts/frequency_options/"
|
||||
value={repeatEveryPeriod}
|
||||
onChange={handleRepeatEveryPeriodChange}
|
||||
value={recurrencePeriod}
|
||||
onChange={onRecurrencePeriodChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -689,14 +685,10 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
<VerticalGroup spacing="md">
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
value={showActiveOnSelectedDays}
|
||||
onChange={handleShowActiveOnSelectedDaysToggle}
|
||||
/>
|
||||
<Switch disabled={disabled} value={isMaskedByWeekdays} onChange={onMaskedByWeekdaysSwitch} />
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">Mask by weekdays</Text>
|
||||
{showActiveOnSelectedDays && (
|
||||
{isMaskedByWeekdays && (
|
||||
<DaysSelector
|
||||
options={store.scheduleStore.byDayOptions}
|
||||
value={selectedDays}
|
||||
|
|
@ -710,21 +702,21 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
|
||||
<HorizontalGroup align="flex-start">
|
||||
<Switch
|
||||
disabled={disabled || repeatEveryPeriod === RepeatEveryPeriod.MONTHS}
|
||||
value={showActiveOnSelectedPartOfDay}
|
||||
onChange={handleShowActiveOnSelectedPartOfDayToggle}
|
||||
disabled={isSelectedPartOfDayDisabled()}
|
||||
value={isLimitShiftEnabled}
|
||||
onChange={onLimitShiftSwitch}
|
||||
/>
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">Limit each shift length</Text>
|
||||
{showActiveOnSelectedPartOfDay && (
|
||||
{isLimitShiftEnabled && (
|
||||
<ShiftPeriod
|
||||
repeatEveryPeriod={showActiveOnSelectedDays ? RepeatEveryPeriod.HOURS : repeatEveryPeriod}
|
||||
repeatEveryPeriod={isMaskedByWeekdays ? RepeatEveryPeriod.HOURS : recurrencePeriod}
|
||||
repeatEveryValue={
|
||||
showActiveOnSelectedDays
|
||||
? repeatEveryPeriod === RepeatEveryPeriod.HOURS
|
||||
? Math.min(repeatEveryValue, 24)
|
||||
isMaskedByWeekdays
|
||||
? recurrencePeriod === RepeatEveryPeriod.HOURS
|
||||
? Math.min(recurrenceNum, 24)
|
||||
: 24
|
||||
: repeatEveryValue
|
||||
: recurrenceNum
|
||||
}
|
||||
defaultValue={shiftPeriodDefaultValue}
|
||||
shiftStart={shiftStart}
|
||||
|
|
@ -733,7 +725,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
{showActiveOnSelectedDays && (
|
||||
{isMaskedByWeekdays && (
|
||||
<Text type="secondary">
|
||||
Since masking by weekdays is enabled, each shift length may not exceed 24hs, and each shift
|
||||
will repeat every day
|
||||
|
|
@ -771,7 +763,9 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
</div>
|
||||
<div>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
|
||||
<Text type="secondary">
|
||||
Current timezone: <Text type="primary">{store.timezoneStore.selectedTimezoneLabel}</Text>
|
||||
</Text>
|
||||
<HorizontalGroup>
|
||||
{shiftId !== 'new' && (
|
||||
<Tooltip content="Stop the current rotation and start a new one">
|
||||
|
|
@ -802,21 +796,19 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
</>
|
||||
);
|
||||
|
||||
async function onResize() {
|
||||
setDraggablePosition({ x: 0, y: await calculateOffsetTop() });
|
||||
function isSelectedPartOfDayDisabled() {
|
||||
// Disable Shift length limit if Monday is enabled without masked weekdays
|
||||
if (recurrencePeriod === RepeatEveryPeriod.MONTHS && !isMaskedByWeekdays) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
|
||||
async function calculateOffsetTop(): Promise<number> {
|
||||
const elm = await waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`);
|
||||
const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement;
|
||||
const coords = getCoords(elm);
|
||||
async function onResize() {
|
||||
setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`));
|
||||
|
||||
const offsetTop = Math.max(
|
||||
Math.min(coords.top - modal?.offsetHeight - 10, document.body.offsetHeight - modal?.offsetHeight - 10),
|
||||
GRAFANA_HEADER_HEIGHT + 10
|
||||
);
|
||||
|
||||
return offsetTop;
|
||||
setDraggablePosition({ x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function onDraggableInit(_e: DraggableEvent, data: DraggableData) {
|
||||
|
|
@ -824,30 +816,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollBarReferenceElements = document.querySelectorAll<HTMLElement>('.scrollbar-view');
|
||||
// top navbar display has 2 scrollbar-view elements (navbar & content)
|
||||
const baseReferenceElRect = (
|
||||
scrollBarReferenceElements.length === 1 ? scrollBarReferenceElements[0] : scrollBarReferenceElements[1]
|
||||
).getBoundingClientRect();
|
||||
|
||||
const { right, bottom } = baseReferenceElRect;
|
||||
|
||||
setDraggableBounds(
|
||||
isTopNavbar()
|
||||
? {
|
||||
// values are adjusted by any padding/margin differences
|
||||
left: -data.node.offsetLeft + 4,
|
||||
right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12,
|
||||
top: -offsetTop + GRAFANA_HEADER_HEIGHT + 4,
|
||||
bottom: bottom - data.node.offsetHeight - offsetTop - 12,
|
||||
}
|
||||
: {
|
||||
left: -data.node.offsetLeft + 4 + GRAFANA_LEGACY_SIDEBAR_WIDTH,
|
||||
right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12,
|
||||
top: -offsetTop + 4,
|
||||
bottom: bottom - data.node.offsetHeight - offsetTop - 12,
|
||||
}
|
||||
);
|
||||
setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { IconButton, VerticalGroup, HorizontalGroup, Field, Button, useTheme2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import Draggable from 'react-draggable';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
import { Modal } from 'components/Modal/Modal';
|
||||
import { Tag } from 'components/Tag/Tag';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { UserGroups } from 'components/UserGroups/UserGroups';
|
||||
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
|
||||
import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers';
|
||||
import { getShiftName } from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, Shift } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
|
||||
import { getDateTime, getUTCString, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { HTML_ID, getCoords, waitForElement } from 'utils/DOM';
|
||||
import { GRAFANA_HEADER_HEIGHT } from 'utils/consts';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
import { useDebouncedCallback, useResize } from 'utils/hooks';
|
||||
|
||||
import { getDraggableModalCoordinatesOnInit } from './RotationForm.helpers';
|
||||
import { DateTimePicker } from './parts/DateTimePicker';
|
||||
import { UserItem } from './parts/UserItem';
|
||||
|
||||
|
|
@ -39,6 +39,9 @@ interface RotationFormProps {
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
|
||||
const {
|
||||
onHide,
|
||||
onCreate,
|
||||
|
|
@ -46,16 +49,18 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
onUpdate,
|
||||
onDelete,
|
||||
shiftId,
|
||||
shiftStart: propsShiftStart = dayjs().startOf('day').add(1, 'day'),
|
||||
shiftStart: propsShiftStart = store.timezoneStore.calendarStartDate,
|
||||
shiftEnd: propsShiftEnd,
|
||||
shiftColor: shiftColorProp,
|
||||
} = props;
|
||||
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
|
||||
const [rotationName, setRotationName] = useState<string>(shiftId === 'new' ? 'Override' : 'Update override');
|
||||
|
||||
const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined);
|
||||
const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(propsShiftStart);
|
||||
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(propsShiftEnd || propsShiftStart.add(24, 'hours'));
|
||||
|
||||
|
|
@ -66,6 +71,10 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
const shiftColor = shiftColorProp || theme.colors.warning.main;
|
||||
|
||||
const debouncedOnResize = useDebouncedCallback(onResize, 250);
|
||||
|
||||
useResize(debouncedOnResize);
|
||||
|
||||
const updateShiftStart = useCallback(
|
||||
(value) => {
|
||||
const diff = shiftEnd.diff(shiftStart);
|
||||
|
|
@ -79,15 +88,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
useEffect(() => {
|
||||
(async () => {
|
||||
if (isOpen) {
|
||||
const elm = await waitForElement(`#${HTML_ID.SCHEDULE_OVERRIDES_AND_SWAPS}`);
|
||||
const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement;
|
||||
const coords = getCoords(elm);
|
||||
const offsetTop = Math.min(
|
||||
Math.max(coords.top - modal?.offsetHeight - 10, GRAFANA_HEADER_HEIGHT + 10),
|
||||
document.body.offsetHeight - modal?.offsetHeight - 10
|
||||
);
|
||||
|
||||
setOffsetTop(offsetTop);
|
||||
setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`));
|
||||
}
|
||||
})();
|
||||
}, [isOpen]);
|
||||
|
|
@ -102,6 +103,11 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
}
|
||||
}, [shiftId]);
|
||||
|
||||
useEffect(() => {
|
||||
setShiftStart(toDateWithTimezoneOffset(shiftStart, store.timezoneStore.selectedTimezoneOffset));
|
||||
setShiftEnd(toDateWithTimezoneOffset(shiftEnd, store.timezoneStore.selectedTimezoneOffset));
|
||||
}, [store.timezoneStore.selectedTimezoneOffset]);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
rotation_start: getUTCString(shiftStart),
|
||||
|
|
@ -200,7 +206,15 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
width="430px"
|
||||
onDismiss={onHide}
|
||||
contentElement={(props, children) => (
|
||||
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
|
||||
<Draggable
|
||||
handle=".drag-handler"
|
||||
defaultClassName={cx('draggable')}
|
||||
positionOffset={{ x: 0, y: offsetTop }}
|
||||
position={draggablePosition}
|
||||
bounds={{ ...bounds } || 'body'}
|
||||
onStart={onDraggableInit}
|
||||
onStop={(_e, data) => setDraggablePosition({ x: data.x, y: data.y })}
|
||||
>
|
||||
<div {...props}>{children}</div>
|
||||
</Draggable>
|
||||
)}
|
||||
|
|
@ -215,7 +229,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
{shiftId !== 'new' && (
|
||||
<WithConfirm>
|
||||
<WithConfirm title="Are you sure you want to delete override?">
|
||||
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
|
||||
</WithConfirm>
|
||||
)}
|
||||
|
|
@ -228,49 +242,70 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<div className={cx('override-form-content')} data-testid="override-inputs">
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override period start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker
|
||||
disabled={disabled}
|
||||
value={shiftStart}
|
||||
onChange={updateShiftStart}
|
||||
error={errors.shift_start}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override period end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker disabled={disabled} value={shiftEnd} onChange={setShiftEnd} error={errors.shift_end} />
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
<UserGroups
|
||||
disabled={disabled}
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={false}
|
||||
renderUser={(pk: ApiSchemas['User']['pk']) => (
|
||||
<UserItem pk={pk} shiftColor={shiftColor} shiftStart={params.shift_start} shiftEnd={params.shift_end} />
|
||||
)}
|
||||
showError={Boolean(errors.rolling_users)}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('override-form-content')} data-testid="override-inputs">
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
data-testid="override-start"
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override period start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker
|
||||
disabled={disabled}
|
||||
value={shiftStart}
|
||||
utcOffset={store.timezoneStore.selectedTimezoneOffset}
|
||||
onChange={updateShiftStart}
|
||||
error={errors.shift_start}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
data-testid="override-end"
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override period end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker
|
||||
disabled={disabled}
|
||||
value={shiftEnd}
|
||||
utcOffset={store.timezoneStore.selectedTimezoneOffset}
|
||||
onChange={setShiftEnd}
|
||||
error={errors.shift_end}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
|
||||
<UserGroups
|
||||
disabled={disabled}
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={false}
|
||||
renderUser={(pk: ApiSchemas['User']['pk']) => (
|
||||
<UserItem
|
||||
pk={pk}
|
||||
shiftColor={shiftColor}
|
||||
shiftStart={params.shift_start}
|
||||
shiftEnd={params.shift_end}
|
||||
/>
|
||||
)}
|
||||
showError={Boolean(errors.rolling_users)}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
|
||||
<Text type="secondary">
|
||||
Current timezone: <Text type="primary">{store.timezoneStore.selectedTimezoneLabel}</Text>
|
||||
</Text>
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={disabled || !isFormValid}>
|
||||
{shiftId === 'new' ? 'Create' : 'Update'}
|
||||
|
|
@ -280,4 +315,18 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
</VerticalGroup>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
async function onResize() {
|
||||
setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`));
|
||||
|
||||
setDraggablePosition({ x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function onDraggableInit(_e: DraggableEvent, data: DraggableData) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const DaysSelector = ({ value, onChange, options: optionsProp, weekStart,
|
|||
<div
|
||||
key={display_name}
|
||||
onClick={getDayClickHandler(itemValue as string)}
|
||||
className={cx('day', { day__selected: value.includes(itemValue as string) })}
|
||||
className={cx('day', { day__selected: value?.includes(itemValue as string) })}
|
||||
>
|
||||
{display_name.substring(0, 2)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers';
|
|||
import { Layer, Shift } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
|
||||
import { waitForElement } from 'utils/DOM';
|
||||
|
||||
export const calculateScheduleFormOffset = async (queryClassName: string) => {
|
||||
const modal = await waitForElement(queryClassName);
|
||||
const modalHeight = modal.clientHeight;
|
||||
|
||||
return document.documentElement.scrollHeight / 2 - modalHeight / 2;
|
||||
};
|
||||
|
||||
// DatePickers will convert the date passed to local timezone, instead we want to use the date in the given timezone
|
||||
export const toDatePickerDate = (value: dayjs.Dayjs, timezoneOffset: number) => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { TimelineMarks } from 'containers/TimelineMarks/TimelineMarks';
|
|||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { getColor, getLayersFromStore, scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, ScheduleType, Shift, ShiftSwap, Event, Layer } from 'models/schedule/schedule.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { getCurrentTimeX, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -34,13 +33,11 @@ interface RotationsProps extends WithStoreProps {
|
|||
layerPriorityToShowRotationForm?: Layer['priority'];
|
||||
scheduleId: Schedule['id'];
|
||||
onShowRotationForm: (shiftId: Shift['id'] | 'new', layerPriority?: Layer['priority']) => void;
|
||||
onClick: (id: Shift['id'] | 'new') => void;
|
||||
onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void;
|
||||
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial<ShiftSwap>) => void;
|
||||
onCreate: () => void;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
onShiftSwapRequest: (beneficiary: ApiSchemas['User']['pk'], swap_start: string, swap_end: string) => void;
|
||||
disabled: boolean;
|
||||
filters: ScheduleFiltersType;
|
||||
onSlotClick?: (event: Event) => void;
|
||||
|
|
@ -362,4 +359,6 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
};
|
||||
}
|
||||
|
||||
export const Rotations = withMobXProviderContext(withTheme2(_Rotations));
|
||||
export const Rotations = withMobXProviderContext(withTheme2(_Rotations)) as unknown as React.ComponentClass<
|
||||
Omit<RotationsProps, 'store' | 'theme'>
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
SHIFT_SWAP_COLOR,
|
||||
} from 'models/schedule/schedule.helpers';
|
||||
import { Schedule, Shift, ShiftEvents, ShiftSwap } from 'models/schedule/schedule.types';
|
||||
import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers';
|
||||
import { getCurrentTimeX, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { HTML_ID } from 'utils/DOM';
|
||||
|
|
@ -39,7 +39,7 @@ interface ScheduleOverridesProps extends WithStoreProps {
|
|||
shiftEndToShowOverrideForm: dayjs.Dayjs;
|
||||
scheduleId: Schedule['id'];
|
||||
shiftIdToShowRotationForm?: Shift['id'] | 'new';
|
||||
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
|
||||
onShowOverridesForm: (shiftId: Shift['id'] | 'new') => void;
|
||||
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new') => void;
|
||||
onCreate: () => void;
|
||||
onUpdate: () => void;
|
||||
|
|
@ -205,8 +205,14 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
shiftId={shiftIdToShowRotationForm}
|
||||
shiftColor={findColor(shiftIdToShowRotationForm, undefined, shifts)}
|
||||
scheduleId={scheduleId}
|
||||
shiftStart={propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm}
|
||||
shiftEnd={propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm}
|
||||
shiftStart={toDateWithTimezoneOffset(
|
||||
propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm,
|
||||
store.timezoneStore.selectedTimezoneOffset
|
||||
)}
|
||||
shiftEnd={toDateWithTimezoneOffset(
|
||||
propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm,
|
||||
store.timezoneStore.selectedTimezoneOffset
|
||||
)}
|
||||
onHide={() => {
|
||||
this.handleHide();
|
||||
|
||||
|
|
@ -241,7 +247,7 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
}
|
||||
|
||||
this.setState({ shiftStartToShowOverrideForm: shiftStart, shiftEndToShowOverrideForm: shiftEnd }, () => {
|
||||
this.onShowRotationForm(shiftId);
|
||||
this.onShowOverridesForm(shiftId);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -256,22 +262,24 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
this.setState(
|
||||
{ shiftStartToShowOverrideForm: store.timezoneStore.currentDateInSelectedTimezone.startOf('day') },
|
||||
() => {
|
||||
this.onShowRotationForm('new');
|
||||
this.onShowOverridesForm('new');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleHide = () => {
|
||||
this.setState({ shiftStartToShowOverrideForm: undefined, shiftEndToShowOverrideForm: undefined }, () => {
|
||||
this.onShowRotationForm(undefined);
|
||||
this.onShowOverridesForm(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
onShowRotationForm = (shiftId: Shift['id']) => {
|
||||
const { onShowRotationForm } = this.props;
|
||||
onShowOverridesForm = (shiftId: Shift['id']) => {
|
||||
const { onShowOverridesForm } = this.props;
|
||||
|
||||
onShowRotationForm(shiftId);
|
||||
onShowOverridesForm(shiftId);
|
||||
};
|
||||
}
|
||||
|
||||
export const ScheduleOverrides = withMobXProviderContext(withTheme2(_ScheduleOverrides));
|
||||
export const ScheduleOverrides = withMobXProviderContext(
|
||||
withTheme2(_ScheduleOverrides)
|
||||
) as unknown as React.ComponentClass<Omit<ScheduleOverridesProps, 'store' | 'theme'>>;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { css } from '@emotion/css';
|
|||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Drawer, Field, HorizontalGroup, Input, useStyles2, Button } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { parseUrl } from 'query-string';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
|
|
@ -12,6 +11,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
|
|||
import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useIsLoading } from 'utils/hooks';
|
||||
import { validateURL } from 'utils/string';
|
||||
import { OmitReadonlyMembers } from 'utils/types';
|
||||
import { openNotification } from 'utils/utils';
|
||||
|
||||
|
|
@ -130,10 +130,6 @@ export const ServiceNowConfigDrawer: React.FC<ServiceNowConfigurationDrawerProps
|
|||
</>
|
||||
);
|
||||
|
||||
function validateURL(urlFieldValue: string): string | boolean {
|
||||
return !parseUrl(urlFieldValue) ? 'Instance URL is invalid' : true;
|
||||
}
|
||||
|
||||
async function onFormSubmit(formData: FormFields): Promise<void> {
|
||||
const data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannel']> = {
|
||||
...currentIntegration,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
withTheme2,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import Linkify from 'linkify-react';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
|
@ -517,7 +518,6 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
renderTimeline = () => {
|
||||
const {
|
||||
store,
|
||||
history,
|
||||
match: {
|
||||
params: { id },
|
||||
},
|
||||
|
|
@ -569,7 +569,17 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
</Text>
|
||||
)}
|
||||
<Text type="primary">
|
||||
{reactStringReplace(item.action, /\{\{([^}]+)\}\}/g, this.getPlaceholderReplaceFn(item, history))}
|
||||
<Linkify
|
||||
options={{
|
||||
render: ({ attributes, content }) => (
|
||||
<a {...attributes} rel="noreferrer noopener" target="_blank">
|
||||
<Text underline>{content}</Text>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{this.replaceTextInResolutionNote(item)}
|
||||
</Linkify>
|
||||
</Text>
|
||||
<Text type="secondary" size="small">
|
||||
{moment(item.created_at).format('MMM DD, YYYY HH:mm:ss Z')}
|
||||
|
|
@ -636,17 +646,14 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
await this.update();
|
||||
};
|
||||
|
||||
getPlaceholderReplaceFn = (entity: any, history) => {
|
||||
getPlaceholderReplaceFn = (entity: TimeLineItem) => {
|
||||
return (match: string) => {
|
||||
switch (match) {
|
||||
case 'author':
|
||||
return (
|
||||
<span
|
||||
onClick={() => history.push(`${PLUGIN_ROOT}/users/${entity?.author?.pk}`)}
|
||||
style={{ textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
{entity.author?.username}
|
||||
</span>
|
||||
<a href={`${PLUGIN_ROOT}/users/${entity?.author?.pk}`} target="_blank" rel="noopener noreferrer">
|
||||
<Text underline>{entity.author?.username}</Text>
|
||||
</a>
|
||||
);
|
||||
default:
|
||||
return '{{' + match + '}}';
|
||||
|
|
@ -654,6 +661,9 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
};
|
||||
};
|
||||
|
||||
replaceTextInResolutionNote = (item: TimeLineItem) =>
|
||||
reactStringReplace(item.action, /\{\{([^}]+)\}\}/g, this.getPlaceholderReplaceFn(item));
|
||||
|
||||
getOnActionButtonClick = (incidentId: ApiSchemas['AlertGroup']['pk'], action: AlertAction) => {
|
||||
const { store } = this.props;
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ interface IncidentsPageState {
|
|||
isFirstIncidentsFetchDone: boolean;
|
||||
}
|
||||
|
||||
const POLLING_NUM_SECONDS = 15;
|
||||
const POLLING_NUM_SECONDS = 10;
|
||||
|
||||
const PAGINATION_OPTIONS = [
|
||||
{ label: '25', value: 25 },
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
|
|||
layerPriorityToShowRotationForm={layerPriorityToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
onShowOverrideForm={this.handleShowOverridesForm}
|
||||
disabled={disabledRotationForm}
|
||||
disabled={Boolean(disabledRotationForm)}
|
||||
filters={filters}
|
||||
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
|
||||
onSlotClick={shiftSwapIdToShowForm ? this.adjustShiftSwapForm : undefined}
|
||||
|
|
@ -431,9 +431,9 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
|
|||
onUpdate={this.refreshEventsAndClearPreview}
|
||||
onDelete={this.refreshEventsAndClearPreview}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
disabled={disabledOverrideForm}
|
||||
disableShiftSwaps={disabledShiftSwaps}
|
||||
onShowOverridesForm={this.handleShowOverridesForm}
|
||||
disabled={Boolean(disabledOverrideForm)}
|
||||
disableShiftSwaps={Boolean(disabledShiftSwaps)}
|
||||
shiftStartToShowOverrideForm={shiftStartToShowOverrideForm}
|
||||
shiftEndToShowOverrideForm={shiftEndToShowOverrideForm}
|
||||
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"id": "grafana-oncall-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others.",
|
||||
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const waitForElement = (selector: string) => {
|
||||
export const waitForElement = (selector: string): Promise<Element> => {
|
||||
return new Promise((resolve) => {
|
||||
if (document.querySelector(selector)) {
|
||||
return resolve(document.querySelector(selector));
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class BaseFaroHelper {
|
|||
context: {
|
||||
url: res?.config?.url,
|
||||
type: 'network',
|
||||
data: `${safeJSONStringify(res.data)}`,
|
||||
data: `${safeJSONStringify(res?.data)}`,
|
||||
status: `${res?.status}`,
|
||||
statusText: `${res?.statusText}`,
|
||||
timestamp: new Date().toUTCString(),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ export function useQueryParams(): URLSearchParams {
|
|||
return React.useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
export function useResize(onResizeHandler: () => void) {
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', onResizeHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResizeHandler);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useDebouncedCallback<A extends any[]>(callback: (...args: A) => void, wait: number) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = useRef<A>();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { parseURL } from './url';
|
||||
|
||||
// Truncate a string to a given maximum length, adding ellipsis if it was truncated.
|
||||
export function truncateTitle(title: string, length: number): string {
|
||||
if (title.length <= length) {
|
||||
|
|
@ -24,4 +26,6 @@ export const safeJSONStringify = (value: unknown) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const VALID_URL_PATTERN = /(http|https)\:\/\/.+?\..+/;
|
||||
export function validateURL(urlFieldValue: string): string | boolean {
|
||||
return !parseURL(urlFieldValue) ? 'URL is invalid' : true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9582,6 +9582,16 @@ lines-and-columns@^1.1.6:
|
|||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
linkify-react@^4.1.3:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.1.3.tgz#461d348b4bdab3fcd0452ae1b5bbc22536395b97"
|
||||
integrity sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==
|
||||
|
||||
linkifyjs@^4.1.3:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f"
|
||||
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==
|
||||
|
||||
lint-staged@^10.2.11:
|
||||
version "10.5.4"
|
||||
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.5.4.tgz#cd153b5f0987d2371fc1d2847a409a2fe705b665"
|
||||
|
|
@ -13459,16 +13469,7 @@ string-template@~0.2.1:
|
|||
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
|
||||
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
|
@ -13586,7 +13587,7 @@ stringify-object@^3.3.0:
|
|||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
|
@ -13607,13 +13608,6 @@ strip-ansi@^5.2.0:
|
|||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
||||
|
|
@ -14954,7 +14948,8 @@ wordwrap@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
name wrap-ansi-cjs
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
|
@ -14972,15 +14967,6 @@ wrap-ansi@^6.2.0:
|
|||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue