Merge pull request #1632 from grafana/dev

Dev to main (v1.2.2)
This commit is contained in:
Vadim Stepanov 2023-03-27 15:29:43 +01:00 committed by GitHub
commit 69a195132d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1046 additions and 384 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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