commit
69a195132d
43 changed files with 1046 additions and 384 deletions
22
.github/ISSUE_TEMPLATE/issue-template.md
vendored
22
.github/ISSUE_TEMPLATE/issue-template.md
vendored
|
|
@ -2,17 +2,23 @@
|
|||
name: General Issue
|
||||
about: General requirements to all issues.
|
||||
title: Specific issue name
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Hi, thank you for opening an issue!
|
||||
`<Remove before publishing>`
|
||||
|
||||
Here is a quick checklist:
|
||||
Hi 👋, thank you for opening an issue!
|
||||
|
||||
- [ ] Is it about Cloud or Open Source OnCall?
|
||||
Please make sure to add such an info to the issue description:
|
||||
|
||||
- [ ] Mention is it's about Cloud or Open Source OnCall.
|
||||
- [ ] Add OnCall backend & frontend versions.
|
||||
- [ ] Include labels starting with "part:". Like `part:alertflow` or `part:schedules`.
|
||||
- [ ] Include labels starting with "part:". Like `part:alertflow` or `part:schedules`. Search for all `part:` labels and
|
||||
choose the closest one.
|
||||
- [ ] Include labels like `bug` or `feature request`.
|
||||
- [ ] If it's a bug, include logs.
|
||||
- [ ] If it's a bug, include logs, scheenshots, videos. As much specific info as possible.
|
||||
|
||||
Issues mising those items will be closed.
|
||||
|
||||
`</Remove before publishing>`
|
||||
|
|
|
|||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
|
|
@ -4,6 +4,6 @@
|
|||
|
||||
## Checklist
|
||||
|
||||
- [ ] Tests updated
|
||||
- [ ] Documentation added
|
||||
- [ ] `CHANGELOG.md` updated
|
||||
- [ ] Unit, integration, and e2e (if applicable) tests updated
|
||||
- [ ] Documentation added (or `pr:no public docs` PR label added if not required)
|
||||
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.2.2 (2023-03-27)
|
||||
|
||||
### Changed
|
||||
|
||||
- Drawers with Forms are not closing by clicking outside of the drawer. Only by clicking Cancel or X (by @Ukochka in [#1608](https://github.com/grafana/oncall/pull/1608))
|
||||
- When the `DANGEROUS_WEBHOOKS_ENABLED` environment variable is set to true, it's possible now to create Outgoing Webhooks
|
||||
using URLs without a top-level domain (by @hoptical in [#1398](https://github.com/grafana/oncall/pull/1398))
|
||||
- Updated wording when creating an integration (by @callmehyde in [#1572](https://github.com/grafana/oncall/pull/1572))
|
||||
- Set FCM iOS/Android "message priority" to "high priority" for mobile app push notifications (by @joeyorlando in [#1612](https://github.com/grafana/oncall/pull/1612))
|
||||
- Improve schedule quality feature (by @vadimkerr in [#1602](https://github.com/grafana/oncall/pull/1602))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update override deletion changes to set its final duration (by @matiasb in [#1599](https://github.com/grafana/oncall/pull/1599))
|
||||
|
||||
## v1.2.1 (2023-03-23)
|
||||
|
||||
### Changed
|
||||
|
||||
- Mobile app settings backend by @vadimkerr in ([1571](https://github.com/grafana/oncall/pull/1571))
|
||||
- Fix integrations and escalations autoselect, improve GList by @maskin25 in ([1601](https://github.com/grafana/oncall/pull/1601))
|
||||
- Add filters to outgoing webhooks 2 by @iskhakov in ([1598](https://github.com/grafana/oncall/pull/1598))
|
||||
|
||||
## v1.2.0 (2023-03-21)
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -46,12 +46,33 @@ To learn more about RBAC for Grafana OnCall, refer to the following documentatio
|
|||
|
||||
## Manage Teams in Grafana OnCall
|
||||
|
||||
Teams in Grafana OnCall are based on the teams created at the organization level of your Grafana instance,
|
||||
in **Configuration > Teams**. Administrators can create a different configuration for each team, and can navigate
|
||||
between team configurations in the **Select Team** dropdown menu in the **Alert Group** section of Grafana OnCall.
|
||||
Teams in Grafana OnCall enable the configuration of visibility and filtering of resources, such as alert groups,
|
||||
integrations, escalation chains, and schedules. OnCall teams are automatically synced with
|
||||
[Grafana teams](https://grafana.com/docs/grafana/latest/administration/team-management/) created at the organization
|
||||
level of your Grafana instance. To modify global settings like team name or team members, navigate to
|
||||
**Configuration > Teams**. For OnCall-specific team settings,
|
||||
go to **Alerts and Incidents > OnCall > Settings > Teams and Access Settings**.
|
||||
|
||||
Users, including admins, can only view and manage teams in OnCall if they are a member of that team.
|
||||
An admin user may need to temporarily add themselves to a team to manage it.
|
||||
This section displays a list of teams, allowing you to configure team visibility and access to team resources for all
|
||||
Grafana users, or only admins and team members. You can also set a default team, which is a user-specific setting;
|
||||
the default team will be pre-selected each time a user creates a new resource. The team list includes a `No team` tag,
|
||||
signifying that the resource has no team and is accessible to everyone.
|
||||
|
||||
Admins can view the list of all teams, while editors and viewers can only see teams (and their resources)
|
||||
they are members of or if the team setting "who can see the team name and access the team resources" is set to
|
||||
"all users of Grafana".
|
||||
|
||||
> ⚠️ In the main Grafana teams section, users can set team-specific user permissions, such as Admin, Editor, or Viewer,
|
||||
> but only for resources within that team. Currently, Grafana OnCall ignores this setting and uses global roles instead.
|
||||
|
||||
Teams help filter resources on their respective pages, improving organization. You can assign a resource to a team when
|
||||
creating it. Alert groups created via the Integration API inherit the team from the integration.
|
||||
|
||||
Resources from different teams can be connected with one another. For instance, you can create an integration in one
|
||||
team, set up multiple routes for the integration, and utilize escalation chains from other teams. Users, schedules,
|
||||
and outgoing webhooks from other teams can also be included in the escalation chain. If a user only has access to the
|
||||
first team and not others, they will be unable to view the resource, which will display as `🔒 Private resource`.
|
||||
This feature enables the distribution of escalations across various teams.
|
||||
|
||||
## Configure user notification policies
|
||||
|
||||
|
|
|
|||
|
|
@ -224,15 +224,19 @@ the following env variables with your SMTP server credentials:
|
|||
|
||||
After enabling the email integration, it will be possible to use the `Notify by email` notification step in user settings.
|
||||
|
||||
Grafana OnCall is also capable of creating alert groups from
|
||||
## Inbound Email Setup
|
||||
|
||||
Grafana OnCall is capable of creating alert groups from
|
||||
[Inbound Email integration]({{< relref "../integrations/available-integrations/configure-inbound-email" >}}).
|
||||
|
||||
To configure Inbound Email integration for Grafana OnCall OSS populate env variables with your Email Service Provider data:
|
||||
|
||||
- `INBOUND_EMAIL_ESP` - Inbound email ESP name. Available options: amazon_ses, mailgun, mailjet, mandrill, postal, postmark, sendgrid, sparkpost
|
||||
- `INBOUND_EMAIL_ESP` - Inbound email ESP name. Available options: `amazon_ses`, `mailgun`, `mailjet`, `mandrill`, `postal`, `postmark`, `sendgrid`, `sparkpost`
|
||||
- `INBOUND_EMAIL_DOMAIN` - Inbound email domain
|
||||
- `INBOUND_EMAIL_WEBHOOK_SECRET` - Inbound email webhook secret
|
||||
|
||||
You will also need to configure your ESP to forward messages to the following URL: `<ONCALL_ENGINE_PUBLIC_URL>/integrations/v1/inbound_email_webhook`.
|
||||
|
||||
## Limits
|
||||
|
||||
By default, Grafana OnCall limits email and phone notifications (calls, SMS) to 200 per user per day.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from rest_framework import serializers
|
|||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from apps.alerts.models import CustomButton
|
||||
from apps.base.utils import live_settings
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
|
||||
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault
|
||||
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, URLValidatorWithoutTLD
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
||||
|
||||
|
|
@ -41,7 +42,10 @@ class CustomButtonSerializer(serializers.ModelSerializer):
|
|||
def validate_webhook(self, webhook):
|
||||
if webhook:
|
||||
try:
|
||||
URLValidator()(webhook)
|
||||
if live_settings.DANGEROUS_WEBHOOKS_ENABLED:
|
||||
URLValidatorWithoutTLD()(webhook)
|
||||
else:
|
||||
URLValidator()(webhook)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError("Webhook is incorrect")
|
||||
return webhook
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from apps.alerts.models import CustomButton
|
|||
from apps.api.permissions import LegacyAccessControlRole
|
||||
|
||||
TEST_URL = "https://amixr.io"
|
||||
URL_WITH_TLD = "http://www.google.com"
|
||||
URL_WITHOUT_TLD = "http://container:8080"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
@ -457,3 +459,36 @@ def test_get_custom_button_from_other_team_with_flag(
|
|||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"dangerous_webhooks,webhook_url,expected_status",
|
||||
[
|
||||
(True, URL_WITH_TLD, status.HTTP_201_CREATED),
|
||||
(True, URL_WITHOUT_TLD, status.HTTP_201_CREATED),
|
||||
(False, URL_WITH_TLD, status.HTTP_201_CREATED),
|
||||
(False, URL_WITHOUT_TLD, status.HTTP_400_BAD_REQUEST),
|
||||
],
|
||||
)
|
||||
def test_url_without_tld_custom_button(
|
||||
custom_button_internal_api_setup,
|
||||
make_user_auth_headers,
|
||||
settings,
|
||||
dangerous_webhooks,
|
||||
webhook_url,
|
||||
expected_status,
|
||||
):
|
||||
settings.DANGEROUS_WEBHOOKS_ENABLED = dangerous_webhooks
|
||||
|
||||
user, token, _ = custom_button_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:custom_button-list")
|
||||
|
||||
data = {
|
||||
"name": "amixr_button",
|
||||
"webhook": webhook_url,
|
||||
"team": None,
|
||||
}
|
||||
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == expected_status
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from apps.auth_token.auth import PluginAuthentication
|
|||
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
|
||||
from apps.auth_token.models import ScheduleExportAuthToken
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.schedules.quality_score import get_schedule_quality_score
|
||||
from apps.slack.models import SlackChannel
|
||||
from apps.slack.tasks import update_slack_user_group_for_schedules
|
||||
from common.api_helpers.exceptions import BadRequest, Conflict
|
||||
|
|
@ -353,13 +352,12 @@ class ScheduleView(
|
|||
@action(detail=True, methods=["get"])
|
||||
def quality(self, request, pk):
|
||||
schedule = self.get_object()
|
||||
user_tz, date = self.get_request_timezone()
|
||||
days = int(self.request.query_params.get("days", 90)) # todo: check if days could be calculated more precisely
|
||||
|
||||
events = schedule.filter_events(user_tz, date, days=days, with_empty=True, with_gap=True)
|
||||
_, date = self.get_request_timezone()
|
||||
days = self.request.query_params.get("days")
|
||||
days = int(days) if days else None
|
||||
|
||||
schedule_score = get_schedule_quality_score(events, days)
|
||||
return Response(schedule_score)
|
||||
return Response(schedule.quality_report(date, days))
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def type_options(self, request):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from celery.utils.log import get_task_logger
|
|||
from django.conf import settings
|
||||
from fcm_django.models import FCMDevice
|
||||
from firebase_admin.exceptions import FirebaseError
|
||||
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from firebase_admin.messaging import AndroidConfig, APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -42,18 +42,19 @@ class FCMRelayView(APIView):
|
|||
token = request.data["token"]
|
||||
data = request.data["data"]
|
||||
apns = request.data["apns"]
|
||||
android = request.data.get("android") # optional
|
||||
except KeyError:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
fcm_relay_async.delay(token=token, data=data, apns=apns)
|
||||
fcm_relay_async.delay(token=token, data=data, apns=apns, android=android)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 5
|
||||
)
|
||||
def fcm_relay_async(token, data, apns):
|
||||
message = Message(token=token, data=data, apns=deserialize_apns(apns))
|
||||
def fcm_relay_async(token, data, apns, android=None):
|
||||
message = _get_message_from_request_data(token, data, apns, android)
|
||||
|
||||
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
|
||||
response = FCMDevice(registration_id=token).send_message(message)
|
||||
|
|
@ -63,7 +64,17 @@ def fcm_relay_async(token, data, apns):
|
|||
raise response
|
||||
|
||||
|
||||
def deserialize_apns(apns):
|
||||
def _get_message_from_request_data(token, data, apns, android):
|
||||
"""
|
||||
Create Message object from JSON payload from OSS instance.
|
||||
"""
|
||||
|
||||
return Message(
|
||||
token=token, data=data, apns=_deserialize_apns(apns), android=AndroidConfig(**android) if android else None
|
||||
)
|
||||
|
||||
|
||||
def _deserialize_apns(apns):
|
||||
"""
|
||||
Create APNSConfig object from JSON payload from OSS instance.
|
||||
"""
|
||||
|
|
@ -95,5 +106,6 @@ def deserialize_apns(apns):
|
|||
sound=sound,
|
||||
custom_data=aps,
|
||||
)
|
||||
)
|
||||
),
|
||||
headers=apns.get("headers"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from celery.utils.log import get_task_logger
|
|||
from django.conf import settings
|
||||
from fcm_django.models import FCMDevice
|
||||
from firebase_admin.exceptions import FirebaseError
|
||||
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from firebase_admin.messaging import AndroidConfig, APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
|
||||
from requests import HTTPError
|
||||
from rest_framework import status
|
||||
|
||||
|
|
@ -185,6 +185,21 @@ def _get_fcm_message(alert_group, user, registration_id, critical):
|
|||
mobile_app_user_settings.important_notification_override_dnd
|
||||
),
|
||||
},
|
||||
android=AndroidConfig(
|
||||
# from the docs
|
||||
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
|
||||
#
|
||||
# Normal priority.
|
||||
# Normal priority messages are delivered immediately when the app is in the foreground.
|
||||
# For backgrounded apps, delivery may be delayed. For less time-sensitive messages, such as notifications
|
||||
# of new email, keeping your UI in sync, or syncing app data in the background, choose normal delivery
|
||||
# priority.
|
||||
#
|
||||
# High priority.
|
||||
# FCM attempts to deliver high priority messages immediately even if the device is in Doze mode.
|
||||
# High priority messages are for time-sensitive, user visible content.
|
||||
priority="high",
|
||||
),
|
||||
apns=APNSConfig(
|
||||
payload=APNSPayload(
|
||||
aps=Aps(
|
||||
|
|
@ -202,5 +217,10 @@ def _get_fcm_message(alert_group, user, registration_id, critical):
|
|||
},
|
||||
),
|
||||
),
|
||||
headers={
|
||||
# From the docs
|
||||
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
|
||||
"apns-priority": "10",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -7,7 +8,8 @@ from firebase_admin.exceptions import FirebaseError
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.mobile_app.fcm_relay import FCMRelayThrottler, fcm_relay_async
|
||||
from apps.mobile_app.fcm_relay import FCMRelayThrottler, _get_message_from_request_data, fcm_relay_async
|
||||
from apps.mobile_app.tasks import _get_fcm_message
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -88,3 +90,41 @@ def test_fcm_relay_async_retry():
|
|||
):
|
||||
with pytest.raises(FirebaseError):
|
||||
fcm_relay_async(token="test_token", data={}, apns={})
|
||||
|
||||
|
||||
def test_get_message_from_request_data():
|
||||
token = "test_token"
|
||||
data = {"test_data_key": "test_data_value"}
|
||||
apns = {"headers": {"apns-priority": "10"}, "payload": {"aps": {"thread-id": "test_thread_id"}}}
|
||||
android = {"priority": "high"}
|
||||
message = _get_message_from_request_data(token, data, apns, android)
|
||||
|
||||
assert message.token == "test_token"
|
||||
assert message.data == {"test_data_key": "test_data_value"}
|
||||
assert message.apns.headers == {"apns-priority": "10"}
|
||||
assert message.apns.payload.aps.thread_id == "test_thread_id"
|
||||
assert message.android.priority == "high"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_relay_serialize_deserialize(
|
||||
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# Imitate sending a message to the FCM relay endpoint
|
||||
original_message = _get_fcm_message(alert_group, user, device.registration_id, critical=False)
|
||||
request_data = json.loads(str(original_message))
|
||||
|
||||
# Imitate receiving a message from the FCM relay endpoint
|
||||
relayed_message = _get_message_from_request_data(
|
||||
request_data["token"], request_data["data"], request_data["apns"], request_data["android"]
|
||||
)
|
||||
|
||||
# Check that the message is the same after serialization and deserialization
|
||||
assert json.loads(str(original_message)) == json.loads(str(relayed_message))
|
||||
|
|
|
|||
|
|
@ -223,8 +223,19 @@ class CustomOnCallShift(models.Model):
|
|||
force = kwargs.pop("force", False)
|
||||
# do soft delete for started shifts that were created for web schedule
|
||||
if self.schedule and self.event_is_started and not force:
|
||||
self.until = timezone.now().replace(microsecond=0)
|
||||
self.save(update_fields=["until"])
|
||||
updated_until = timezone.now().replace(microsecond=0)
|
||||
if self.until is not None and updated_until >= self.until:
|
||||
# event is already finished
|
||||
return
|
||||
self.until = updated_until
|
||||
update_fields = ["until"]
|
||||
if self.type == self.TYPE_OVERRIDE:
|
||||
# since it is a single-time event, update override duration
|
||||
delta = self.until - self.start
|
||||
if delta < self.duration:
|
||||
self.duration = delta
|
||||
update_fields += ["duration"]
|
||||
self.save(update_fields=update_fields)
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import datetime
|
||||
import functools
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from typing import Iterable, Optional, TypedDict
|
||||
|
||||
import icalendar
|
||||
import pytz
|
||||
|
|
@ -23,9 +26,33 @@ from apps.schedules.ical_utils import (
|
|||
list_of_oncall_shifts_from_ical,
|
||||
)
|
||||
from apps.schedules.models import CustomOnCallShift
|
||||
from apps.user_management.models import User
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
|
||||
# Utility classes for schedule quality report
|
||||
class QualityReportCommentType(str, Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
class QualityReportComment(TypedDict):
|
||||
type: QualityReportCommentType
|
||||
text: str
|
||||
|
||||
|
||||
class QualityReportOverloadedUser(TypedDict):
|
||||
id: str
|
||||
username: str
|
||||
score: int
|
||||
|
||||
|
||||
class QualityReport(TypedDict):
|
||||
total_score: int
|
||||
comments: list[QualityReportComment]
|
||||
overloaded_users: list[QualityReportOverloadedUser]
|
||||
|
||||
|
||||
def generate_public_primary_key_for_oncall_schedule_channel():
|
||||
prefix = "S"
|
||||
new_public_primary_key = generate_public_primary_key(prefix)
|
||||
|
|
@ -256,6 +283,126 @@ class OnCallSchedule(PolymorphicModel):
|
|||
events = self._resolve_schedule(events)
|
||||
return events
|
||||
|
||||
def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport:
|
||||
"""
|
||||
Return schedule quality report to be used by the web UI.
|
||||
TODO: Add scores on "inside working hours" and "balance outside working hours" when
|
||||
TODO: working hours editor is implemented in the web UI.
|
||||
"""
|
||||
# get events to consider for calculation
|
||||
if date is None:
|
||||
today = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
date = today - datetime.timedelta(days=7 - today.weekday()) # start of next week in UTC
|
||||
if days is None:
|
||||
days = 52 * 7 # consider next 52 weeks (~1 year)
|
||||
|
||||
events = self.final_events(user_tz="UTC", starting_date=date, days=days)
|
||||
|
||||
# an event is “good” if it's not a gap and not empty
|
||||
good_events = [event for event in events if not event["is_gap"] and not event["is_empty"]]
|
||||
if not good_events:
|
||||
return {
|
||||
"total_score": 0,
|
||||
"comments": [{"type": QualityReportCommentType.WARNING, "text": "Schedule is empty"}],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
def event_duration(ev: dict) -> datetime.timedelta:
|
||||
return ev["end"] - ev["start"]
|
||||
|
||||
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
|
||||
return sum(deltas, start=datetime.timedelta())
|
||||
|
||||
def score_to_percent(value: float) -> int:
|
||||
return round(value * 100)
|
||||
|
||||
def get_duration_map(evs: list[dict]) -> dict[str, datetime.timedelta]:
|
||||
"""Return a map of user PKs to total duration of events they are in."""
|
||||
result = defaultdict(datetime.timedelta)
|
||||
for ev in evs:
|
||||
for user in ev["users"]:
|
||||
user_pk = user["pk"]
|
||||
result[user_pk] += event_duration(ev)
|
||||
|
||||
return result
|
||||
|
||||
def get_balance_score_by_duration_map(dur_map: dict[str, datetime.timedelta]) -> float:
|
||||
"""
|
||||
Return a score between 0 and 1, based on how balanced the durations are in the duration map.
|
||||
The formula is taken from https://github.com/grafana/oncall/issues/118#issuecomment-1161787854.
|
||||
"""
|
||||
if len(dur_map) <= 1:
|
||||
return 1
|
||||
|
||||
result = 0
|
||||
for key_1, key_2 in itertools.combinations(dur_map, 2):
|
||||
duration_1 = dur_map[key_1]
|
||||
duration_2 = dur_map[key_2]
|
||||
|
||||
result += min(duration_1, duration_2) / max(duration_1, duration_2)
|
||||
|
||||
number_of_pairs = len(dur_map) * (len(dur_map) - 1) // 2
|
||||
return result / number_of_pairs
|
||||
|
||||
# calculate good event score
|
||||
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
|
||||
good_event_score = min(good_events_duration / datetime.timedelta(days=days), 1)
|
||||
good_event_score = score_to_percent(good_event_score)
|
||||
|
||||
# calculate balance score
|
||||
duration_map = get_duration_map(good_events)
|
||||
balance_score = get_balance_score_by_duration_map(duration_map)
|
||||
balance_score = score_to_percent(balance_score)
|
||||
|
||||
# calculate overloaded users
|
||||
if balance_score >= 95: # tolerate minor imbalance
|
||||
balance_score = 100
|
||||
overloaded_users = []
|
||||
else:
|
||||
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
|
||||
overloaded_user_pks = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
|
||||
usernames = {
|
||||
u.public_primary_key: u.username
|
||||
for u in User.objects.filter(public_primary_key__in=overloaded_user_pks).only(
|
||||
"public_primary_key", "username"
|
||||
)
|
||||
}
|
||||
overloaded_users = []
|
||||
for user_pk in overloaded_user_pks:
|
||||
score = score_to_percent(duration_map[user_pk] / average_duration) - 100
|
||||
username = usernames.get(user_pk) or "unknown" # fallback to "unknown" if user is not found
|
||||
overloaded_users.append({"id": user_pk, "username": username, "score": score})
|
||||
|
||||
# show most overloaded users first
|
||||
overloaded_users.sort(key=lambda u: (-u["score"], u["username"]))
|
||||
|
||||
# generate comments regarding gaps
|
||||
comments = []
|
||||
if good_event_score == 100:
|
||||
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule has no gaps"})
|
||||
else:
|
||||
not_covered = 100 - good_event_score
|
||||
comments.append(
|
||||
{"type": QualityReportCommentType.WARNING, "text": f"Schedule has gaps ({not_covered}% not covered)"}
|
||||
)
|
||||
|
||||
# generate comments regarding balance
|
||||
if balance_score == 100:
|
||||
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule is perfectly balanced"})
|
||||
else:
|
||||
comments.append(
|
||||
{"type": QualityReportCommentType.WARNING, "text": "Schedule has balance issues (see overloaded users)"}
|
||||
)
|
||||
|
||||
# calculate total score (weighted sum of good event score and balance score)
|
||||
total_score = round((good_event_score + balance_score) / 2)
|
||||
|
||||
return {
|
||||
"total_score": total_score,
|
||||
"comments": comments,
|
||||
"overloaded_users": overloaded_users,
|
||||
}
|
||||
|
||||
def _resolve_schedule(self, events):
|
||||
"""Calculate final schedule shifts considering rotations and overrides."""
|
||||
if not events:
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import datetime
|
||||
import enum
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from typing import Iterable, Union
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
class CommentType(str, enum.Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
# TODO: add "inside working hours score" and "balance outside working hours score" when working hours editor is implemented
|
||||
def get_schedule_quality_score(events: list[dict], days: int) -> dict:
|
||||
# an event is “good” if it's a primary event, not a gap and not empty
|
||||
good_events = [
|
||||
event for event in events if not event["is_override"] and not event["is_gap"] and not event["is_empty"]
|
||||
]
|
||||
good_event_score = get_good_event_score(good_events, days)
|
||||
|
||||
# formula for balance score is taken from here: https://github.com/grafana/oncall/issues/118
|
||||
balance_score, overloaded_users = get_balance_score(good_events)
|
||||
|
||||
if events:
|
||||
total_score = (good_event_score + balance_score) / 2
|
||||
else:
|
||||
total_score = 0
|
||||
|
||||
comments = []
|
||||
if good_event_score < 1:
|
||||
comments.append({"type": CommentType.WARNING, "text": "Schedule has gaps"})
|
||||
else:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule has no gaps"})
|
||||
|
||||
if balance_score < 0.8:
|
||||
comments.append({"type": CommentType.WARNING, "text": "Schedule has balance issues"})
|
||||
elif 0.8 <= balance_score < 1:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule is well-balanced, but still can be improved"})
|
||||
else:
|
||||
comments.append({"type": CommentType.INFO, "text": "Schedule is perfectly balanced"})
|
||||
|
||||
return {
|
||||
"total_score": score_to_percent(total_score),
|
||||
"comments": comments,
|
||||
"overloaded_users": overloaded_users,
|
||||
}
|
||||
|
||||
|
||||
def get_good_event_score(good_events: list[dict], days: int) -> float:
|
||||
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
|
||||
good_event_score = min(
|
||||
good_events_duration / datetime.timedelta(days=days), 1
|
||||
) # todo: deal with overlapping events
|
||||
|
||||
return good_event_score
|
||||
|
||||
|
||||
def get_balance_score(events: list[dict]) -> tuple[float, list[str]]:
|
||||
duration_map = defaultdict(datetime.timedelta)
|
||||
for event in events:
|
||||
for user in event["users"]:
|
||||
user_pk = user["pk"]
|
||||
duration_map[user_pk] += event_duration(event)
|
||||
|
||||
if len(duration_map) == 0:
|
||||
return 1, []
|
||||
|
||||
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
|
||||
overloaded_users = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
|
||||
|
||||
return get_balance_score_by_duration_map(duration_map), overloaded_users
|
||||
|
||||
|
||||
def get_balance_score_by_duration_map(duration_map: dict[str, datetime.timedelta]) -> float:
|
||||
if len(duration_map) <= 1:
|
||||
return 1
|
||||
|
||||
score = 0
|
||||
for key_1, key_2 in itertools.combinations(duration_map, 2):
|
||||
duration_1 = duration_map[key_1]
|
||||
duration_2 = duration_map[key_2]
|
||||
|
||||
score += min(duration_1, duration_2) / max(duration_1, duration_2)
|
||||
|
||||
number_of_pairs = len(duration_map) * (len(duration_map) - 1) // 2
|
||||
balance_score = score / number_of_pairs
|
||||
return balance_score
|
||||
|
||||
|
||||
def get_day_start(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
|
||||
return datetime.datetime.combine(dt, datetime.datetime.min.time(), tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
def get_day_end(dt: Union[datetime.datetime, datetime.date]) -> datetime.datetime:
|
||||
return datetime.datetime.combine(dt, datetime.datetime.max.time(), tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
def event_duration(event: dict) -> datetime.timedelta:
|
||||
start = event["start"]
|
||||
end = event["end"]
|
||||
|
||||
if event["all_day"]:
|
||||
start = get_day_start(start)
|
||||
# adding one microsecond to the end datetime to make sure 1 day-long events are really 1 day long
|
||||
end = get_day_end(end) + datetime.timedelta(microseconds=1)
|
||||
|
||||
return end - start
|
||||
|
||||
|
||||
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
|
||||
return sum(deltas, start=datetime.timedelta())
|
||||
|
||||
|
||||
def score_to_percent(score: float) -> int:
|
||||
return round(score * 100)
|
||||
|
|
@ -1585,3 +1585,43 @@ def test_delete_shift(make_organization_and_user, make_schedule, make_on_call_sh
|
|||
else:
|
||||
on_call_shift.refresh_from_db()
|
||||
assert on_call_shift.until is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"starting_day,duration,deleted",
|
||||
[
|
||||
(-1, 2, False),
|
||||
(-2, 1, False),
|
||||
(1, 1, True),
|
||||
],
|
||||
)
|
||||
def test_delete_override(
|
||||
make_organization_and_user, make_schedule, make_on_call_shift, starting_day, duration, deleted
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
start_date = (timezone.now() + timezone.timedelta(days=starting_day)).replace(microsecond=0)
|
||||
|
||||
data = {
|
||||
"start": start_date,
|
||||
"rotation_start": start_date,
|
||||
"duration": timezone.timedelta(days=duration),
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data)
|
||||
original_duration = on_call_shift.duration
|
||||
|
||||
on_call_shift.delete()
|
||||
|
||||
if deleted:
|
||||
with pytest.raises(CustomOnCallShift.DoesNotExist):
|
||||
on_call_shift.refresh_from_db()
|
||||
else:
|
||||
on_call_shift.refresh_from_db()
|
||||
assert on_call_shift.until is not None
|
||||
assert (
|
||||
on_call_shift.duration == original_duration
|
||||
if (starting_day + duration) < 0
|
||||
else on_call_shift.duration < original_duration
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||
from apps.schedules.models import OnCallScheduleICal
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleICal, OnCallScheduleWeb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -54,8 +56,7 @@ def test_get_schedule_score_no_events(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 0,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
{"type": "warning", "text": "Schedule is empty"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
|
@ -67,12 +68,18 @@ def test_get_schedule_score_09_05(get_schedule_quality_response):
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
assert response.json() == {
|
||||
"total_score": 27,
|
||||
"total_score": 28,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "warning", "text": "Schedule has balance issues"},
|
||||
{"type": "warning", "text": "Schedule has gaps (79% not covered)"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user1.public_primary_key,
|
||||
"username": user1.username,
|
||||
"score": 49,
|
||||
},
|
||||
],
|
||||
"overloaded_users": [user1.public_primary_key],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -84,10 +91,16 @@ def test_get_schedule_score_09_09(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 51,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "info", "text": "Schedule is well-balanced, but still can be improved"},
|
||||
{"type": "warning", "text": "Schedule has gaps (81% not covered)"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user2.public_primary_key,
|
||||
"username": user2.username,
|
||||
"score": 9,
|
||||
},
|
||||
],
|
||||
"overloaded_users": [user2.public_primary_key],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -113,8 +126,233 @@ def test_get_schedule_score_09_19(get_schedule_quality_response):
|
|||
assert response.json() == {
|
||||
"total_score": 70,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps"},
|
||||
{"type": "warning", "text": "Schedule has gaps (59% not covered)"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_weekdays(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 86,
|
||||
"comments": [
|
||||
{"type": "warning", "text": "Schedule has gaps (29% not covered)"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_all_week(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=24),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["SA", "SU"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 100,
|
||||
"comments": [
|
||||
{"type": "info", "text": "Schedule has no gaps"},
|
||||
{"type": "info", "text": "Schedule is perfectly balanced"},
|
||||
],
|
||||
"overloaded_users": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_score_all_week_imbalanced_weekends(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization = make_organization()
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_quality",
|
||||
)
|
||||
|
||||
users = [make_user_for_organization(organization, username=f"user-{idx}") for idx in range(8)]
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 0, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=12),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[4:]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["MO", "TU", "WE", "TH", "FR"],
|
||||
)
|
||||
|
||||
make_on_call_shift(
|
||||
schedule.organization,
|
||||
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
schedule=schedule,
|
||||
start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
duration=datetime.timedelta(hours=24),
|
||||
rotation_start=datetime.datetime(2022, 3, 20, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
until=None,
|
||||
rolling_users=[{user.pk: user.public_primary_key for user in users[:4]}],
|
||||
frequency=CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
by_day=["SA", "SU"],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-quality", kwargs={"pk": schedule.public_primary_key}) + "?date=2022-03-24"
|
||||
response = client.get(url, **make_user_auth_headers(users[0], token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_score": 88,
|
||||
"comments": [
|
||||
{"type": "info", "text": "Schedule has no gaps"},
|
||||
{"type": "warning", "text": "Schedule has balance issues (see overloaded users)"},
|
||||
],
|
||||
"overloaded_users": [
|
||||
{
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"score": 29,
|
||||
}
|
||||
for user in users[:4]
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import datetime
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils import dateparse, timezone
|
||||
from django.utils.regex_helper import _lazy_re_compile
|
||||
from icalendar import Calendar
|
||||
from rest_framework import serializers
|
||||
|
||||
|
|
@ -45,6 +48,34 @@ class CurrentTeamDefault:
|
|||
return "%s()" % self.__class__.__name__
|
||||
|
||||
|
||||
class URLValidatorWithoutTLD(URLValidator):
|
||||
"""
|
||||
Overrides Django URLValidator Regex. It removes the tld part because
|
||||
most of the time, containers don't have any TLD in their urls and such outgoing webhooks
|
||||
can't be registered.
|
||||
"""
|
||||
|
||||
host_re = (
|
||||
"("
|
||||
+ URLValidator.hostname_re
|
||||
+ URLValidator.domain_re
|
||||
+ URLValidator.tld_re
|
||||
+ "|"
|
||||
+ URLValidator.hostname_re
|
||||
+ "|localhost)"
|
||||
)
|
||||
|
||||
regex = _lazy_re_compile(
|
||||
r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
|
||||
r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
|
||||
r"(?:" + URLValidator.ipv4_re + "|" + URLValidator.ipv6_re + "|" + host_re + ")"
|
||||
r"(?::[0-9]{1,5})?" # port
|
||||
r"(?:[/?#][^\s]*)?" # resource path
|
||||
r"\Z",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class CurrentUserDefault:
|
||||
"""
|
||||
Utility class to get the current user right from the serializer field.
|
||||
|
|
|
|||
20
engine/common/tests/test_urlvalidator_without_tld.py
Normal file
20
engine/common/tests/test_urlvalidator_without_tld.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import pytest
|
||||
from django.core.validators import ValidationError
|
||||
|
||||
from common.api_helpers.utils import URLValidatorWithoutTLD
|
||||
|
||||
valid_urls = ["https://www.google.com", "https://www.google", "http://conatainer1"]
|
||||
invalid_urls = ["https:/www.google.com", "htt://www.google.com/"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", valid_urls)
|
||||
def test_urlvalidator_without_tld_valid_urls(url):
|
||||
# Test valid URLs
|
||||
URLValidatorWithoutTLD()(url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", invalid_urls)
|
||||
def test_urlvalidator_without_tld_invalid_urls(url):
|
||||
# Test an invalid URL
|
||||
with pytest.raises(ValidationError):
|
||||
URLValidatorWithoutTLD()(url)
|
||||
|
|
@ -40,3 +40,22 @@ if settings.OTEL_TRACING_ENABLED and settings.OTEL_EXPORTER_OTLP_ENDPOINT:
|
|||
except ModuleNotFoundError:
|
||||
# Only works under uwsgi web server environment
|
||||
pass
|
||||
|
||||
if settings.PYROSCOPE_PROFILER_ENABLED:
|
||||
try:
|
||||
import pyroscope
|
||||
from uwsgidecorators import postfork
|
||||
|
||||
@postfork
|
||||
def init_pyroscope():
|
||||
pyroscope.configure(
|
||||
application_name=settings.PYROSCOPE_APPLICATION_NAME,
|
||||
server_address=settings.PYROSCOPE_SERVER_ADDRESS,
|
||||
auth_token=settings.PYROSCOPE_AUTH_TOKEN,
|
||||
detect_subprocesses=True,
|
||||
tags={"celery_worker": settings.PYROSCOPE_CELERY_WORKER_QUEUE},
|
||||
)
|
||||
|
||||
except ModuleNotFoundError:
|
||||
# Only works under uwsgi web server environment
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -678,15 +678,7 @@ MIGRATION_LINTER_OPTIONS = {"exclude_apps": ["social_django", "silk", "fcm_djang
|
|||
MIGRATION_LINTER_OVERRIDE_MAKEMIGRATIONS = True
|
||||
|
||||
PYROSCOPE_PROFILER_ENABLED = getenv_boolean("PYROSCOPE_PROFILER_ENABLED", default=False)
|
||||
if PYROSCOPE_PROFILER_ENABLED:
|
||||
import pyroscope
|
||||
|
||||
pyroscope.configure(
|
||||
application_name=os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall"),
|
||||
server_address=os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040"),
|
||||
auth_token=os.getenv("PYROSCOPE_AUTH_TOKEN", ""),
|
||||
detect_subprocesses=True,
|
||||
tags={
|
||||
"celery_worker": os.getenv("CELERY_WORKER_QUEUE", None),
|
||||
},
|
||||
)
|
||||
PYROSCOPE_APPLICATION_NAME = os.getenv("PYROSCOPE_APPLICATION_NAME", "oncall")
|
||||
PYROSCOPE_SERVER_ADDRESS = os.getenv("PYROSCOPE_SERVER_ADDRESS", "http://pyroscope:4040")
|
||||
PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "")
|
||||
PYROSCOPE_CELERY_WORKER_QUEUE = os.getenv("CELERY_WORKER_QUEUE", None)
|
||||
|
|
|
|||
19
grafana-plugin/integration-tests/schedules/quality.test.ts
Normal file
19
grafana-plugin/integration-tests/schedules/quality.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { configureOnCallPlugin } from '../utils/configurePlugin';
|
||||
import { generateRandomValue } from '../utils/forms';
|
||||
import { createOnCallSchedule } from '../utils/schedule';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await configureOnCallPlugin(page);
|
||||
});
|
||||
|
||||
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
|
||||
const onCallScheduleName = generateRandomValue();
|
||||
await createOnCallSchedule(page, onCallScheduleName);
|
||||
|
||||
await expect(page.locator('div[class*="ScheduleQuality"]')).toHaveText('Quality: Great');
|
||||
|
||||
await page.hover('div[class*="ScheduleQuality"]');
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=2 ')).toHaveText('Schedule has no gaps');
|
||||
await expect(page.locator('div[class*="ScheduleQualityDetails"] >> span[class*="Text"] >> nth=3 ')).toHaveText('Schedule is perfectly balanced');
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Field, Form, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
|
|
@ -17,22 +17,57 @@ const nullNormalizer = (value: string) => {
|
|||
return value || null;
|
||||
};
|
||||
|
||||
function renderFormControl(formItem: FormItem, register: any, control: any) {
|
||||
function renderFormControl(formItem: FormItem, register: any, control: any, onChangeFn: () => void) {
|
||||
switch (formItem.type) {
|
||||
case FormItemType.Input:
|
||||
return <Input {...register(formItem.name, formItem.validation)} />;
|
||||
return <Input {...register(formItem.name, formItem.validation)} onChange={onChangeFn} />;
|
||||
|
||||
case FormItemType.TextArea:
|
||||
return <TextArea rows={formItem.extra?.rows || 4} {...register(formItem.name, formItem.validation)} />;
|
||||
return (
|
||||
<TextArea
|
||||
rows={formItem.extra?.rows || 4}
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
onChange={onChangeFn}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.MultiSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<GSelect
|
||||
isMulti={true}
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChangeFn();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Select:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return <Select {...field} {...formItem.extra} onChange={(value) => onChange(value.value)} />;
|
||||
return (
|
||||
<Select
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
onChange={(value) => {
|
||||
onChange(value.value);
|
||||
onChangeFn();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
// @ts-ignore
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
|
@ -40,26 +75,33 @@ function renderFormControl(formItem: FormItem, register: any, control: any) {
|
|||
case FormItemType.GSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return <GSelect {...field} {...formItem.extra} />;
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<GSelect
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
onChangeFn();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
// @ts-ignore
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Switch:
|
||||
return <Switch {...register(formItem.name, formItem.validation)} />;
|
||||
return <Switch {...register(formItem.name, formItem.validation)} onChange={onChangeFn} />;
|
||||
|
||||
case FormItemType.RemoteSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return <RemoteSelect {...field} {...formItem.extra} />;
|
||||
return <RemoteSelect {...field} {...formItem.extra} onChange={onChangeFn} />;
|
||||
}}
|
||||
control={control}
|
||||
// @ts-ignore
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
|
@ -69,45 +111,53 @@ function renderFormControl(formItem: FormItem, register: any, control: any) {
|
|||
}
|
||||
}
|
||||
|
||||
const GForm = (props: GFormProps) => {
|
||||
const { data, form, onSubmit } = props;
|
||||
class GForm extends React.Component<GFormProps, {}> {
|
||||
render() {
|
||||
const { form, data } = this.props;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: any) => {
|
||||
const normalizedData = Object.keys(data).reduce((acc, key) => {
|
||||
const formItem = form.fields.find((formItem) => formItem.name === key);
|
||||
return (
|
||||
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={this.handleSubmit}>
|
||||
{({ register, errors, control, getValues, setValue }) => {
|
||||
return form.fields.map((formItem: FormItem, formIndex: number) => {
|
||||
if (formItem.isVisible && !formItem.isVisible(getValues())) {
|
||||
setValue(formItem.name, undefined); // clear input value on hide
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = formItem?.normalize ? formItem.normalize(data[key]) : nullNormalizer(data[key]);
|
||||
return (
|
||||
<Field
|
||||
key={formIndex}
|
||||
disabled={formItem.getDisabled ? formItem.getDisabled(data) : false}
|
||||
label={formItem.label || capitalCase(formItem.name)}
|
||||
invalid={!!errors[formItem.name]}
|
||||
error={`${capitalCase(formItem.name)} is required`}
|
||||
description={formItem.description}
|
||||
>
|
||||
{renderFormControl(formItem, register, control, () => this.forceUpdate())}
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
};
|
||||
}, {});
|
||||
handleSubmit = (data) => {
|
||||
const { form, onSubmit } = this.props;
|
||||
|
||||
onSubmit(normalizedData);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
const normalizedData = Object.keys(data).reduce((acc, key) => {
|
||||
const formItem = form.fields.find((formItem) => formItem.name === key);
|
||||
|
||||
return (
|
||||
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={handleSubmit}>
|
||||
{({ register, errors, control }) => {
|
||||
return form.fields.map((formItem: FormItem, formIndex: number) => (
|
||||
<Field
|
||||
key={formIndex}
|
||||
disabled={formItem.getDisabled ? formItem.getDisabled(data) : false}
|
||||
label={formItem.label || capitalCase(formItem.name)}
|
||||
invalid={!!errors[formItem.name]}
|
||||
error={`${capitalCase(formItem.name)} is required`}
|
||||
description={formItem.description}
|
||||
>
|
||||
{renderFormControl(formItem, register, control)}
|
||||
</Field>
|
||||
));
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
const value = formItem?.normalize ? formItem.normalize(data[key]) : nullNormalizer(data[key]);
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
};
|
||||
}, {});
|
||||
|
||||
onSubmit(normalizedData);
|
||||
};
|
||||
}
|
||||
|
||||
export default GForm;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum FormItemType {
|
||||
'Input' = 'input',
|
||||
'TextArea' = 'textarea',
|
||||
'MultiSelect' = 'multiselect',
|
||||
'Select' = 'select',
|
||||
'GSelect' = 'gselect',
|
||||
'Switch' = 'switch',
|
||||
|
|
@ -13,6 +14,7 @@ export interface FormItem {
|
|||
type: FormItemType;
|
||||
description?: string;
|
||||
normalize?: (value: any) => any;
|
||||
isVisible?: (data: any) => any;
|
||||
getDisabled?: (value: any) => any;
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Drawer scrollableContent title="Create manual alert group" onClose={onHide} closeOnMaskClick>
|
||||
<Drawer scrollableContent title="Create manual alert group" onClose={onHide} closeOnMaskClick={false}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<EscalationVariants
|
||||
value={{ userResponders, scheduleResponders }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
$padding: 8px;
|
||||
$width: 280px;
|
||||
$width: 340px;
|
||||
|
||||
.root {
|
||||
width: $width;
|
||||
|
|
@ -53,7 +53,7 @@ $width: 280px;
|
|||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.email {
|
||||
.username {
|
||||
max-width: calc($width - $padding);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, Icon, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import { ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
import styles from './ScheduleQualityDetails.module.scss';
|
||||
|
|
@ -22,24 +18,13 @@ interface ScheduleQualityDetailsProps {
|
|||
}
|
||||
|
||||
export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ quality, getScheduleQualityString }) => {
|
||||
const { userStore } = useStore();
|
||||
const { total_score: score, comments, overloaded_users } = quality;
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [overloadedUsers, setOverloadedUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded((expanded) => !expanded);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoComments = comments.filter((c) => c.type === 'info');
|
||||
const warningComments = comments.filter((c) => c.type === 'warning');
|
||||
|
||||
|
|
@ -94,15 +79,15 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</>
|
||||
)}
|
||||
|
||||
{overloadedUsers?.length > 0 && (
|
||||
{overloaded_users?.length > 0 && (
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('row')}>
|
||||
<Icon name="users-alt" />
|
||||
<div className={cx('container')}>
|
||||
<Text type="secondary">Overloaded users</Text>
|
||||
{overloadedUsers.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx('email')} key={index}>
|
||||
{overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))})
|
||||
{overloaded_users.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx('username')} key={index}>
|
||||
{overloadedUser.username} (+{overloadedUser.score}% avg)
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -125,7 +110,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</HorizontalGroup>
|
||||
{expanded && (
|
||||
<Text type="primary" className={cx('text')}>
|
||||
The next 90 days are taken into consideration when calculating the overall schedule quality.
|
||||
The next 52 weeks are taken into consideration when calculating the overall schedule quality.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -133,25 +118,6 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
</div>
|
||||
);
|
||||
|
||||
async function fetchUsers() {
|
||||
if (!overloaded_users?.length) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsersList: User[] = userStore.getSearchResult().results;
|
||||
const overloadedUsers = [];
|
||||
|
||||
allUsersList.forEach((user) => {
|
||||
if (overloaded_users.indexOf(user['pk']) !== -1) {
|
||||
overloadedUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setOverloadedUsers(overloadedUsers);
|
||||
}
|
||||
|
||||
function getScheduleQualityMatchingColor(score: number): string {
|
||||
if (score < 20) {
|
||||
return getVar('--tag-text-danger');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover .copyButton,
|
||||
&:hover .copyIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
|
|
@ -19,12 +24,5 @@
|
|||
top: 15px;
|
||||
right: 15px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.root:hover .copyButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { Button, IconButton } from '@grafana/ui';
|
||||
import { Button, IconButton, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
|
|
@ -31,7 +31,9 @@ const SourceCode: FC<SourceCodeProps> = (props) => {
|
|||
}}
|
||||
>
|
||||
{showClipboardIconOnly ? (
|
||||
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" data-testid="test__copyIcon" />
|
||||
<Tooltip placement="top" content="Copy to Clipboard">
|
||||
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" data-testid="test__copyIcon" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
className={cx('copyButton')}
|
||||
|
|
|
|||
|
|
@ -578,7 +578,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
</>
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
Select Escalation Chain first please ↑ or
|
||||
Select Escalation Chain ↑ or
|
||||
<Button
|
||||
fill="text"
|
||||
size="sm"
|
||||
|
|
@ -588,7 +588,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
});
|
||||
}}
|
||||
>
|
||||
Create a new
|
||||
Create a new one
|
||||
</Button>{' '}
|
||||
</Text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
};
|
||||
|
||||
const values = isMulti
|
||||
? (value as string[])
|
||||
? (value ? (value as string[]) : [])
|
||||
.filter((id) => id in model.items)
|
||||
.map((id: string) => ({
|
||||
value: id,
|
||||
|
|
@ -127,7 +127,7 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
useEffect(() => {
|
||||
const values = isMulti ? value : [value];
|
||||
|
||||
(values as string[]).forEach((value: string) => {
|
||||
(values ? (values as string[]) : []).forEach((value: string) => {
|
||||
if (!isNil(value) && !model.items[value] && model.updateItem) {
|
||||
model.updateItem(value, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button, Drawer, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import Text from 'components/Text/Text';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { MaintenanceType } from 'models/maintenance/maintenance.types';
|
||||
|
|
@ -52,24 +51,20 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title={
|
||||
<Text.Title className={cx('title')} level={4}>
|
||||
Start Maintenance Mode
|
||||
</Text.Title>
|
||||
}
|
||||
onClose={onHide}
|
||||
closeOnMaskClick
|
||||
>
|
||||
<Drawer scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<GForm form={form} data={initialData} onSubmit={handleSubmit} />
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button form={form.name} type="submit">
|
||||
Start
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button form={form.name} type="submit">
|
||||
Start
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { KeyValuePair } from 'utils';
|
||||
|
||||
export const WebhookTriggerType = {
|
||||
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
|
||||
Triggered: new KeyValuePair('1', 'Triggered'),
|
||||
Acknowledged: new KeyValuePair('2', 'Acknowledged'),
|
||||
Resolved: new KeyValuePair('3', 'Resolved'),
|
||||
Silenced: new KeyValuePair('4', 'Silenced'),
|
||||
Unsilenced: new KeyValuePair('5', 'Unsilenced'),
|
||||
Unresolved: new KeyValuePair('6', 'Unresolved'),
|
||||
};
|
||||
|
||||
export const form: { name: string; fields: FormItem[] } = {
|
||||
name: 'OutgoingWebhook2',
|
||||
|
|
@ -29,28 +40,32 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
extra: {
|
||||
options: [
|
||||
{
|
||||
value: '1',
|
||||
label: 'Triggered',
|
||||
value: WebhookTriggerType.EscalationStep.key,
|
||||
label: WebhookTriggerType.EscalationStep.value,
|
||||
},
|
||||
{
|
||||
value: '2',
|
||||
label: 'Acknowledged',
|
||||
value: WebhookTriggerType.Triggered.key,
|
||||
label: WebhookTriggerType.Triggered.value,
|
||||
},
|
||||
{
|
||||
value: '3',
|
||||
label: 'Resolved',
|
||||
value: WebhookTriggerType.Acknowledged.key,
|
||||
label: WebhookTriggerType.Acknowledged.value,
|
||||
},
|
||||
{
|
||||
value: '4',
|
||||
label: 'Silenced',
|
||||
value: WebhookTriggerType.Resolved.key,
|
||||
label: WebhookTriggerType.Resolved.value,
|
||||
},
|
||||
{
|
||||
value: '5',
|
||||
label: 'Unsilenced',
|
||||
value: WebhookTriggerType.Silenced.key,
|
||||
label: WebhookTriggerType.Silenced.value,
|
||||
},
|
||||
{
|
||||
value: '6',
|
||||
label: 'Unresolved',
|
||||
value: WebhookTriggerType.Unsilenced.key,
|
||||
label: WebhookTriggerType.Unsilenced.value,
|
||||
},
|
||||
{
|
||||
value: WebhookTriggerType.Unresolved.key,
|
||||
label: WebhookTriggerType.Unresolved.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -84,17 +99,34 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
],
|
||||
},
|
||||
},
|
||||
/*
|
||||
* TODO: Uncomment once backend implements it too
|
||||
{
|
||||
name: 'alert_receive_channel_id',
|
||||
label: 'Integrations',
|
||||
type: FormItemType.MultiSelect,
|
||||
isVisible: (data) => {
|
||||
return data.trigger_type !== WebhookTriggerType.EscalationStep.key;
|
||||
},
|
||||
extra: {
|
||||
modelName: 'alertReceiveChannelStore',
|
||||
displayField: 'verbal_name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
|
||||
},
|
||||
validation: { required: true },
|
||||
},
|
||||
*/
|
||||
{
|
||||
name: 'url',
|
||||
label: 'Webhook URL',
|
||||
description: 'Supports templating',
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
label: 'Webhook Headers',
|
||||
description: 'Must be a JSON dict, templating allowed',
|
||||
type: FormItemType.TextArea,
|
||||
extra: {
|
||||
rows: 5,
|
||||
|
|
@ -124,7 +156,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
name: 'data',
|
||||
getDisabled: (form_data) => Boolean(form_data?.forward_whole_payload),
|
||||
type: FormItemType.TextArea,
|
||||
description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}, {{ responses }}',
|
||||
description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}',
|
||||
extra: {
|
||||
rows: 9,
|
||||
},
|
||||
|
|
@ -133,7 +165,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
name: 'forward_all',
|
||||
normalize: (value) => Boolean(value),
|
||||
type: FormItemType.Switch,
|
||||
description: "Forwards whole payload of the alert to the webhook's url as data",
|
||||
description: "Forwards whole payload of the alert to the webhook's url as POST data",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -26,11 +26,11 @@ function Debug(props) {
|
|||
<Label>{props.title}</Label>
|
||||
<Block bordered fullWidth>
|
||||
<VerticalGroup spacing="none">
|
||||
{props.source && <SourceCode>{props.source}</SourceCode>}
|
||||
{props.source && <SourceCode showClipboardIconOnly>{props.source}</SourceCode>}
|
||||
{props.result && props.result !== props.source && (
|
||||
<VerticalGroup spacing="none">
|
||||
<Label>Result</Label>
|
||||
<SourceCode>{props.result}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{props.result}</SourceCode>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
|
|
@ -62,14 +62,14 @@ const OutgoingWebhook2Status = observer((props: OutgoingWebhook2StatusProps) =>
|
|||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<Label>Webhook Name</Label>
|
||||
<SourceCode>{data.name}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{data.name}</SourceCode>
|
||||
<Label>Trigger Type</Label>
|
||||
<SourceCode>{data.trigger_type_name}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{data.trigger_type_name}</SourceCode>
|
||||
|
||||
{data.last_run ? (
|
||||
<VerticalGroup>
|
||||
<Label>Last Run Time</Label>
|
||||
<SourceCode>{data.last_response_log.timestamp}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{data.last_response_log.timestamp}</SourceCode>
|
||||
|
||||
{data.last_response_log.request_trigger && (
|
||||
<Debug
|
||||
|
|
@ -91,14 +91,16 @@ const OutgoingWebhook2Status = observer((props: OutgoingWebhook2StatusProps) =>
|
|||
{data.last_response_log.status_code && (
|
||||
<VerticalGroup>
|
||||
<Label>Response Code</Label>
|
||||
<SourceCode>{data.last_response_log.status_code}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{data.last_response_log.status_code}</SourceCode>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
|
||||
{data.last_response_log.content && (
|
||||
<VerticalGroup>
|
||||
<Label>Response Body</Label>
|
||||
<SourceCode>{JSON.stringify(data.last_response_log.content, null, 4)}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>
|
||||
{JSON.stringify(data.last_response_log.content, null, 4)}
|
||||
</SourceCode>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button, Drawer } from '@grafana/ui';
|
||||
import { Button, Drawer, HorizontalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import Text from 'components/Text/Text';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
|
@ -47,21 +46,22 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
return (
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title={
|
||||
<Text.Title className={cx('title')} level={4}>
|
||||
{id === 'new' ? 'Create' : 'Edit'} Outgoing Webhook
|
||||
</Text.Title>
|
||||
}
|
||||
title={id === 'new' ? 'Create Outgoing Webhook' : 'Edit Outgoing Webhook'}
|
||||
onClose={onHide}
|
||||
closeOnMaskClick
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<div className={cx('content')} data-testid="test__outgoingWebhookEditForm">
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} />
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button form={form.name} type="submit">
|
||||
{id === 'new' ? 'Create' : 'Update'} Webhook
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button form={form.name} type="submit">
|
||||
{id === 'new' ? 'Create' : 'Update'} Webhook
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
}, []);
|
||||
|
||||
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
|
||||
const disableAction = !endLess && rotationEnd.isBefore(dayjs().tz(currentTimezone));
|
||||
|
||||
const [focusElementName, setFocusElementName] = useState<undefined | string>(undefined);
|
||||
|
||||
|
|
@ -419,7 +420,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
<Button variant="secondary" onClick={onHide}>
|
||||
{shiftId === 'new' ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid || disableAction}>
|
||||
{shiftId === 'new' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
const updatePreview = () => {
|
||||
store.scheduleStore
|
||||
.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params)
|
||||
.then(() => {
|
||||
.finally(() => {
|
||||
setIsOpen(true);
|
||||
});
|
||||
};
|
||||
|
|
@ -179,6 +179,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
const handleChange = useDebouncedCallback(updatePreview, 200);
|
||||
|
||||
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
|
||||
const disableAction = shiftEnd.isBefore(dayjs().tz(currentTimezone));
|
||||
|
||||
useEffect(handleChange, [params]);
|
||||
|
||||
|
|
@ -244,7 +245,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
<Button variant="secondary" onClick={onHide}>
|
||||
{shiftId === 'new' ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid || disableAction}>
|
||||
{shiftId === 'new' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { Button, Drawer, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import Text from 'components/Text/Text';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
|
@ -66,22 +65,23 @@ const ScheduleForm = observer((props: ScheduleFormProps) => {
|
|||
return (
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title={
|
||||
<Text.Title className={cx('title')} level={4}>
|
||||
{id === 'new' ? 'New' : 'Edit'} Schedule
|
||||
</Text.Title>
|
||||
}
|
||||
title={id === 'new' ? 'New Schedule' : 'Edit Schedule'}
|
||||
onClose={onHide}
|
||||
closeOnMaskClick
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<GForm form={formConfig} data={data} onSubmit={handleSubmit} />
|
||||
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
|
||||
<Button form={formConfig.name} type="submit">
|
||||
{id === 'new' ? 'Create' : 'Update'} Schedule
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
|
||||
<Button form={formConfig.name} type="submit">
|
||||
{id === 'new' ? 'Create' : 'Update'} Schedule
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
|
|
|||
|
|
@ -188,8 +188,7 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
|
||||
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
|
||||
const tomorrow = getFromString(dayjs().add(1, 'day'));
|
||||
return await makeRequest(`/schedules/${scheduleId}/quality?date=${tomorrow}`, { method: 'GET' });
|
||||
return await makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' });
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export interface ShiftEvents {
|
|||
export interface ScheduleScoreQualityResponse {
|
||||
total_score: number;
|
||||
comments: Array<{ type: 'warning' | 'info'; text: string }>;
|
||||
overloaded_users: string[];
|
||||
overloaded_users: Array<{ id: string; username: string; score: number }>;
|
||||
}
|
||||
|
||||
export enum ScheduleScoreQualityResult {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
action: UserActions.ChatOpsRead,
|
||||
},
|
||||
|
||||
{
|
||||
icon: 'link',
|
||||
id: 'outgoing_webhooks_2',
|
||||
text: 'Outgoing Webhooks 2',
|
||||
path: getPath('outgoing_webhooks_2'),
|
||||
hideFromBreadcrumbs: true,
|
||||
hideFromTabs: true,
|
||||
action: UserActions.OutgoingWebhooksRead,
|
||||
},
|
||||
{
|
||||
icon: 'wrench',
|
||||
id: 'maintenance',
|
||||
|
|
@ -188,7 +197,7 @@ export const ROUTES = {
|
|||
schedules: ['schedules'],
|
||||
schedule: ['schedules/:id'],
|
||||
outgoing_webhooks: ['outgoing_webhooks', 'outgoing_webhooks/:id'],
|
||||
outgoing_webhooks_2: ['outgoing_webhooks_2', 'outgoing_webhooks_2/:id'],
|
||||
outgoing_webhooks_2: ['outgoing_webhooks_2', 'outgoing_webhooks_2/:id', 'outgoing_webhooks_2/:action/:id'],
|
||||
maintenance: ['maintenance'],
|
||||
settings: ['settings'],
|
||||
'organization-logs': ['organization-logs'],
|
||||
|
|
|
|||
|
|
@ -2,4 +2,14 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.header__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.header__desc {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import LegacyNavHeading from 'navbar/LegacyNavHeading';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
|
||||
|
|
@ -24,7 +25,6 @@ import { ActionDTO } from 'models/action';
|
|||
import { FiltersValues } from 'models/filters/filters.types';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
|
|
@ -34,7 +34,15 @@ import styles from './OutgoingWebhooks2.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface OutgoingWebhooks2Props extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
|
||||
const Action = {
|
||||
STATUS: 'status',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
interface OutgoingWebhooks2Props
|
||||
extends WithStoreProps,
|
||||
PageProps,
|
||||
RouteComponentProps<{ id: string; action: string }> {}
|
||||
|
||||
interface OutgoingWebhooks2State extends PageBaseState {
|
||||
outgoingWebhook2IdToEdit?: OutgoingWebhook2['id'] | 'new';
|
||||
|
|
@ -66,7 +74,7 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
const {
|
||||
store,
|
||||
match: {
|
||||
params: { id },
|
||||
params: { id, action },
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
|
|
@ -83,8 +91,10 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
}
|
||||
|
||||
if (outgoingWebhook2 || isNewWebhook) {
|
||||
if (isNewWebhook || (action === Action.EDIT && outgoingWebhook2)) {
|
||||
this.setState({ outgoingWebhook2IdToEdit: id });
|
||||
} else if (action === Action.STATUS && outgoingWebhook2) {
|
||||
this.setState({ outgoingWebhook2IdToShowStatus: id });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -120,11 +130,13 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
width: '35%',
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
render: this.renderUrl,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Last run',
|
||||
dataIndex: 'last_run',
|
||||
render: this.renderLastRun,
|
||||
},
|
||||
{
|
||||
width: '15%',
|
||||
|
|
@ -153,15 +165,18 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<LegacyNavHeading>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={3}>Outgoing Webhooks 2</Text.Title>
|
||||
<Text type="secondary">
|
||||
⚠️ Preview Functionality! Things will change and things will break! Do not use for critical
|
||||
production processes!
|
||||
<div className="header__title">
|
||||
<VerticalGroup spacing="sm">
|
||||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Outgoing Webhooks 2</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary" className={cx('header__desc')}>
|
||||
<Icon name="exclamation-triangle"></Icon> Preview Functionality! Things will change and things
|
||||
will break! Do not use for critical production processes!
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</LegacyNavHeading>
|
||||
</div>
|
||||
|
||||
<div className="u-pull-right">
|
||||
<PluginLink
|
||||
query={{ page: 'outgoing_webhooks_2', id: 'new' }}
|
||||
|
|
@ -235,12 +250,12 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
return (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<WithPermissionControlTooltip key={'status_action'} userAction={UserActions.OutgoingWebhooksRead}>
|
||||
<Button onClick={this.getStatusClickHandler(record.id)} fill="text">
|
||||
<Button onClick={() => this.onStatusClick(record.id)} fill="text">
|
||||
Status
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip key={'edit_action'} userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button onClick={this.getEditClickHandler(record.id)} fill="text">
|
||||
<Button onClick={() => this.onEditClick(record.id)} fill="text">
|
||||
Edit
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -255,40 +270,57 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
);
|
||||
};
|
||||
|
||||
renderUrl(url: string) {
|
||||
return (
|
||||
<div className="u-break-word">
|
||||
<span>{url}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLastRun(lastRun: string) {
|
||||
// TODO: remove replace when backend will update lastRun to a correct timestamp
|
||||
const lastRunMoment = moment(lastRun.replace(' (200 OK)', ''));
|
||||
|
||||
return (
|
||||
<VerticalGroup spacing="none">
|
||||
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('MMM DD, YYYY') : '-'}</Text>
|
||||
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('hh:mm A') : ''}</Text>
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
|
||||
getDeleteClickHandler = (id: OutgoingWebhook2['id']) => {
|
||||
const { store } = this.props;
|
||||
|
||||
return () => {
|
||||
makeRequest(`/webhooks/${id}/`, {
|
||||
method: 'DELETE',
|
||||
withCredentials: true,
|
||||
});
|
||||
store.outgoingWebhook2Store.delete(id).then(this.update);
|
||||
};
|
||||
};
|
||||
|
||||
getEditClickHandler = (id: OutgoingWebhook2['id']) => {
|
||||
onEditClick = (id: OutgoingWebhook2['id']) => {
|
||||
const { history } = this.props;
|
||||
|
||||
return () => {
|
||||
this.setState({ outgoingWebhook2IdToEdit: id, outgoingWebhook2IdToShowStatus: undefined });
|
||||
this.setState({ outgoingWebhook2IdToEdit: id, outgoingWebhook2IdToShowStatus: undefined });
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/${id}`);
|
||||
};
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`);
|
||||
};
|
||||
|
||||
onStatusClick = (id: OutgoingWebhook2['id']) => {
|
||||
const { history } = this.props;
|
||||
|
||||
this.setState({ outgoingWebhook2IdToEdit: undefined, outgoingWebhook2IdToShowStatus: id });
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/status/${id}`);
|
||||
};
|
||||
|
||||
handleOutgoingWebhookFormHide = () => {
|
||||
const { history } = this.props;
|
||||
|
||||
this.setState({ outgoingWebhook2IdToEdit: undefined, outgoingWebhook2IdToShowStatus: undefined });
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2`);
|
||||
};
|
||||
|
||||
getStatusClickHandler = (id: OutgoingWebhook2['id']) => {
|
||||
return () => {
|
||||
const { history } = this.props;
|
||||
this.setState({ outgoingWebhook2IdToEdit: undefined, outgoingWebhook2IdToShowStatus: id });
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/${id}`);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export { OutgoingWebhooks2 };
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import appEvents from 'grafana/app/core/app_events';
|
|||
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
|
||||
import qs from 'query-string';
|
||||
|
||||
export class KeyValuePair {
|
||||
key: string;
|
||||
export class KeyValuePair<T = string | number> {
|
||||
key: T;
|
||||
value: string;
|
||||
|
||||
constructor(key: string, value: string) {
|
||||
constructor(key: T, value: string) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue