This commit is contained in:
Joey Orlando 2024-07-05 15:10:53 -04:00 committed by GitHub
commit 46017acbd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 874 additions and 508 deletions

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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**

View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -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
)

View file

@ -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:

View file

@ -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,

View file

@ -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",
]

View file

@ -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>",

View file

@ -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:
"""

View file

@ -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()

View file

@ -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

View 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,
)

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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")

View file

@ -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,

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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):

View file

@ -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 models 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,),
)

View file

@ -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):

View file

@ -3,5 +3,4 @@ from .exceptions import ( # noqa: F401
MaintenanceCouldNotBeStartedError,
TeamCanNotBeChangedError,
UnableToSendDemoAlert,
UserNotificationPolicyCouldNotBeDeleted,
)

View file

@ -19,10 +19,6 @@ class UnableToSendDemoAlert(OperationCouldNotBePerformedError):
pass
class UserNotificationPolicyCouldNotBeDeleted(OperationCouldNotBePerformedError):
pass
class BacksyncIntegrationRequestError(Exception):
"""Error making request to alert receive channel backsync connection."""

View file

@ -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",

View file

@ -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"},

View file

@ -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,

View file

@ -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,

View file

@ -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();
}
});

View 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();
}
});

View file

@ -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' });

View file

@ -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);

View file

@ -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);

View file

@ -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');

View file

@ -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');

View file

@ -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';

View file

@ -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
};

View file

@ -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) => {

View file

@ -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",

View file

@ -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;

View file

@ -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 }}

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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};
}
`,
};
};

View file

@ -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,
};
}

View file

@ -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));
}
});

View file

@ -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));
}
};

View file

@ -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>

View file

@ -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) => {

View file

@ -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'>
>;

View file

@ -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'>>;

View file

@ -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,

View file

@ -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;

View file

@ -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 },

View file

@ -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}

View file

@ -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"

View file

@ -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));

View file

@ -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(),

View file

@ -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>();

View file

@ -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;
}

View file

@ -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"