Merge pull request #776 from grafana/dev
Release new helm chart version 1.0.9 (v1.0.50)
This commit is contained in:
commit
144abaabc6
35 changed files with 1358 additions and 767 deletions
|
|
@ -1,5 +1,9 @@
|
|||
# Change Log
|
||||
|
||||
## v1.0.51 (2022-11-05)
|
||||
|
||||
- Bug Fixes
|
||||
|
||||
## v1.0.50 (2022-11-03)
|
||||
|
||||
- Updates to documentation
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -11,7 +11,7 @@ PYTEST = $(ENV)/bin/pytest
|
|||
DOCKER_FILE ?= docker-compose-developer.yml
|
||||
|
||||
define setup_engine_env
|
||||
export `grep -v '^#' .env | xargs -0` && cd engine
|
||||
export `grep -v '^#' .env.dev | xargs -0` && cd engine
|
||||
endef
|
||||
|
||||
$(ENV):
|
||||
|
|
|
|||
|
|
@ -29,6 +29,4 @@ Grafana OnCall is an open source incident response management tool built to help
|
|||
- **Massive scalability:** Grafana OnCall is equipped with a full API and Terraform capabilities. Ready for GitOps and large organization configuration.
|
||||
|
||||
|
||||
> **Note:** You can use [Grafana Cloud](https://grafana.com/products/cloud/?plcmt=nav-products-cta1&cta=cloud) to avoid installing, maintaining, and scaling your own instance of Grafana OnCall. The free forever plan includes 30 Grafana OnCall notification. [Create an account to get started](https://grafana.com/auth/sign-up/create-user?pg=oncall&plcmt=hero-btn-1).
|
||||
|
||||
{{< section >}}
|
||||
|
|
|
|||
|
|
@ -18,3 +18,6 @@ Once Grafana OnCall receives an alert, the following occurs, based on the alert
|
|||
- Default or customized alert templates are applied to deliver the most useful alert fields with the most valuable information, in a readable format.
|
||||
- Alerts are grouped based on your alert grouping configurations, combining similar or related alerts to reduce alert noise.
|
||||
- Alerts automatically resolve if an alert from the monitoring system matches the resolve condition for that alert.
|
||||
|
||||
|
||||
{{< section >}}
|
||||
|
|
@ -26,10 +26,7 @@ These procedures introduce you to initial Grafana OnCall configuration steps, in
|
|||
|
||||
## Before you begin
|
||||
|
||||
Grafana OnCall is available for Grafana Cloud as well as Grafana open source users. You must have a Grafana Cloud account or [Open Source Grafana OnCall]({{< relref "../open-source" >}})
|
||||
|
||||
For more information, see [Grafana Pricing](https://grafana.com/pricing/) for details.
|
||||
|
||||
Grafana OnCall is available for Grafana Cloud as well as Grafana open source users. You must have a Grafana Cloud account or use [Open Source Grafana OnCall]({{< relref "../open-source" >}})
|
||||
|
||||
## Install Open Source Grafana OnCall
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.9-alpine
|
||||
FROM python:3.9-alpine3.16
|
||||
RUN apk add bash python3-dev build-base linux-headers pcre-dev mariadb-connector-c-dev openssl-dev libffi-dev git
|
||||
RUN pip install uwsgi
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from django.dispatch import receiver
|
|||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from emoji import emojize
|
||||
from jinja2 import Template
|
||||
|
||||
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
|
||||
from apps.alerts.integration_options_mixin import IntegrationOptionsMixin
|
||||
|
|
@ -29,6 +28,7 @@ from apps.slack.utils import post_message_to_channel
|
|||
from common.api_helpers.utils import create_engine_url
|
||||
from common.exceptions import TeamCanNotBeChangedError, UnableToSendDemoAlert
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
from common.jinja_templater import jinja_template_env
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -360,7 +360,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
def description(self):
|
||||
if self.integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
|
||||
contact_points = self.contact_points.all()
|
||||
rendered_description = Template(self.config.description).render(
|
||||
rendered_description = jinja_template_env.from_string(self.config.description).render(
|
||||
is_finished_alerting_setup=self.is_finished_alerting_setup,
|
||||
grafana_alerting_entities=[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ from django.core.validators import MinLengthValidator
|
|||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from jinja2 import Template
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from common.jinja_templater import jinja_template_env
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -103,7 +103,7 @@ class CustomButton(models.Model):
|
|||
if self.forward_whole_payload:
|
||||
post_kwargs["json"] = alert.raw_request_data
|
||||
elif self.data:
|
||||
rendered_data = Template(self.data).render(
|
||||
rendered_data = jinja_template_env.from_string(self.data).render(
|
||||
{
|
||||
"alert_payload": self._escape_alert_payload(alert.raw_request_data),
|
||||
"alert_group_id": alert.group.public_primary_key,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import json
|
|||
from collections import defaultdict
|
||||
|
||||
from django.core.validators import URLValidator, ValidationError
|
||||
from jinja2 import Template, TemplateError
|
||||
from jinja2 import TemplateError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from apps.alerts.models import CustomButton
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
|
||||
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault
|
||||
from common.jinja_templater import jinja_template_env
|
||||
|
||||
|
||||
class CustomButtonSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -52,7 +53,7 @@ class CustomButtonSerializer(serializers.ModelSerializer):
|
|||
return None
|
||||
|
||||
try:
|
||||
template = Template(data)
|
||||
template = jinja_template_env.from_string(data)
|
||||
except TemplateError:
|
||||
raise serializers.ValidationError("Data has incorrect template")
|
||||
|
||||
|
|
|
|||
|
|
@ -114,8 +114,12 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
raise serializers.ValidationError(
|
||||
{"frequency": ["Cannot set 'frequency' for shifts with type 'override'"]}
|
||||
)
|
||||
if frequency != CustomOnCallShift.FREQUENCY_WEEKLY and by_day:
|
||||
if frequency not in (CustomOnCallShift.FREQUENCY_WEEKLY, CustomOnCallShift.FREQUENCY_DAILY) and by_day:
|
||||
raise serializers.ValidationError({"by_day": ["Cannot set days value for this frequency type"]})
|
||||
if frequency == CustomOnCallShift.FREQUENCY_DAILY and by_day and interval > len(by_day):
|
||||
raise serializers.ValidationError(
|
||||
{"interval": ["Interval must be less than or equal to the number of selected days"]}
|
||||
)
|
||||
|
||||
def _validate_rotation_start(self, shift_start, rotation_start):
|
||||
if rotation_start < shift_start:
|
||||
|
|
|
|||
|
|
@ -739,7 +739,7 @@ def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_set
|
|||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data["by_day"][0] == "Cannot set days value for non-recurrent shifts"
|
||||
|
||||
# by_day with non-weekly frequency
|
||||
# by_day with non-weekly/non-daily frequency
|
||||
data = {
|
||||
"title": "Test Shift 2",
|
||||
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
|
|
@ -749,7 +749,7 @@ def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_set
|
|||
"shift_end": (start_date + timezone.timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"rotation_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"until": None,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_MONTHLY,
|
||||
"interval": None,
|
||||
"by_day": [CustomOnCallShift.ICAL_WEEKDAY_MAP[CustomOnCallShift.MONDAY]],
|
||||
"rolling_users": [[user1.public_primary_key]],
|
||||
|
|
@ -789,6 +789,27 @@ def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_s
|
|||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data["interval"][0] == "Cannot set interval for non-recurrent shifts"
|
||||
|
||||
# by_day, daily, interval > len(by_day)
|
||||
data = {
|
||||
"title": "Test Shift 2",
|
||||
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
|
||||
"schedule": schedule.public_primary_key,
|
||||
"priority_level": 0,
|
||||
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"shift_end": (start_date + timezone.timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"rotation_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"until": None,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"interval": 2,
|
||||
"by_day": [CustomOnCallShift.ICAL_WEEKDAY_MAP[CustomOnCallShift.MONDAY]],
|
||||
"rolling_users": [[user1.public_primary_key]],
|
||||
}
|
||||
|
||||
response = client.post(url, data, format="json", **make_user_auth_headers(user1, token))
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data["interval"][0] == "Interval must be less than or equal to the number of selected days"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_on_call_shift_invalid_data_shift_end(on_call_shift_internal_api_setup, make_user_auth_headers):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import datetime
|
||||
|
||||
import pytz
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
|
|
@ -295,7 +297,11 @@ class ScheduleView(
|
|||
users = {u: None for u in schedule.related_users()}
|
||||
for e in events:
|
||||
user = e["users"][0]["pk"] if e["users"] else None
|
||||
if user is not None and users.get(user) is None and e["end"] > now:
|
||||
event_end = e["end"]
|
||||
if not isinstance(event_end, datetime.datetime):
|
||||
# all day events end is a date, make it a datetime for comparison
|
||||
event_end = datetime.datetime.combine(event_end, datetime.datetime.min.time(), tzinfo=pytz.UTC)
|
||||
if user is not None and users.get(user) is None and event_end > now:
|
||||
users[user] = e
|
||||
|
||||
result = {"users": users}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import json
|
|||
from collections import defaultdict
|
||||
|
||||
from django.core.validators import URLValidator, ValidationError
|
||||
from jinja2 import Template, TemplateError
|
||||
from jinja2 import TemplateError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from apps.alerts.models import CustomButton
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
|
||||
from common.api_helpers.utils import CurrentOrganizationDefault
|
||||
from common.jinja_templater import jinja_template_env
|
||||
|
||||
|
||||
class ActionCreateSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -56,7 +57,7 @@ class ActionCreateSerializer(serializers.ModelSerializer):
|
|||
return None
|
||||
|
||||
try:
|
||||
template = Template(data)
|
||||
template = jinja_template_env.from_string(data)
|
||||
except TemplateError:
|
||||
raise serializers.ValidationError("Data has incorrect template")
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
|
|||
self._validate_frequency_daily(
|
||||
validated_data["type"],
|
||||
validated_data.get("frequency"),
|
||||
validated_data.get("interval"),
|
||||
validated_data.get("by_day"),
|
||||
validated_data.get("by_monthday"),
|
||||
)
|
||||
|
|
@ -201,14 +202,16 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
|
|||
elif frequency == CustomOnCallShift.FREQUENCY_WEEKLY and week_start is None:
|
||||
raise BadRequest(detail="Field 'week_start' is required for frequency type 'weekly'")
|
||||
|
||||
def _validate_frequency_daily(self, event_type, frequency, by_day, by_monthday):
|
||||
def _validate_frequency_daily(self, event_type, frequency, interval, by_day, by_monthday):
|
||||
if event_type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT:
|
||||
if frequency == CustomOnCallShift.FREQUENCY_DAILY:
|
||||
if by_day or by_monthday:
|
||||
if by_monthday:
|
||||
raise BadRequest(
|
||||
detail="Day limits are temporarily disabled for on-call shifts with type 'rolling_users' "
|
||||
"and frequency 'daily'"
|
||||
)
|
||||
if by_day and interval > len(by_day):
|
||||
raise BadRequest(detail="Interval must be less than or equal to the number of selected days")
|
||||
|
||||
def _validate_start_rotation_from_user_index(self, type, index):
|
||||
if type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT and index is None:
|
||||
|
|
@ -354,9 +357,10 @@ class CustomOnCallShiftUpdateSerializer(CustomOnCallShiftSerializer):
|
|||
if frequency != instance.frequency:
|
||||
self._validate_frequency_and_week_start(event_type, frequency, week_start)
|
||||
|
||||
interval = validated_data.get("interval", instance.interval)
|
||||
by_day = validated_data.get("by_day", instance.by_day)
|
||||
by_monthday = validated_data.get("by_monthday", instance.by_monthday)
|
||||
self._validate_frequency_daily(event_type, frequency, by_day, by_monthday)
|
||||
self._validate_frequency_daily(event_type, frequency, interval, by_day, by_monthday)
|
||||
|
||||
if start_rotation_from_user_index != instance.start_rotation_from_user_index:
|
||||
self._validate_start_rotation_from_user_index(event_type, start_rotation_from_user_index)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ invalid_field_data_8 = {
|
|||
"until": "not-a-date",
|
||||
}
|
||||
|
||||
invalid_field_data_9 = {
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"interval": 5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
|
||||
|
|
@ -284,6 +289,7 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal
|
|||
invalid_field_data_6,
|
||||
invalid_field_data_7,
|
||||
invalid_field_data_8,
|
||||
invalid_field_data_9,
|
||||
],
|
||||
)
|
||||
def test_update_on_call_shift_invalid_field(make_organization_and_user_with_token, make_on_call_shift, data_to_update):
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ class AmixrRecurringIcalEventsAdapter(IcalService):
|
|||
|
||||
def filter_extra_days(event):
|
||||
event_start, event_end = self.get_start_and_end_with_respect_to_event_type(event)
|
||||
if event_start > event_end:
|
||||
return False
|
||||
return time_span_contains_event(start_date, end_date, event_start, event_end)
|
||||
|
||||
return list(filter(filter_extra_days, events))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import copy
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
|
|
@ -274,6 +276,70 @@ class CustomOnCallShift(models.Model):
|
|||
|
||||
return is_finished
|
||||
|
||||
def _daily_by_day_to_ical(self, time_zone, start, users_queue):
|
||||
"""Create ical weekly shifts to distribute user groups combining daily + by_day.
|
||||
|
||||
e.g.
|
||||
by_day: [WED, FRI]
|
||||
users_queue: [user_group_1, user_group_2, user_group_3]
|
||||
will result in the following ical shift rules:
|
||||
user_group_1, weekly WED interval 3
|
||||
user_group_2, weekly FRI interval 3
|
||||
user_group_3, weekly WED interval 3
|
||||
user_group_1, weekly FRI interval 3
|
||||
user_group_2, weekly WED interval 3
|
||||
user_group_3, weekly FRI interval 3
|
||||
"""
|
||||
result = ""
|
||||
# keep tracking of (users, day) combinations, and starting dates for each
|
||||
combinations = []
|
||||
starting_dates = []
|
||||
# we may need to iterate several times over users until we get a seen combination
|
||||
# use the group index as reference since user groups could repeat in the queue
|
||||
cycle_user_groups = itertools.cycle(range(len(users_queue)))
|
||||
orig_start = last_start = start
|
||||
all_rotations_checked = False
|
||||
# we need to go through each individual day
|
||||
day_by_day_rrule = copy.deepcopy(self.event_ical_rules)
|
||||
day_by_day_rrule["interval"] = 1
|
||||
for user_group_id in cycle_user_groups:
|
||||
for i in range(self.interval):
|
||||
if not start: # means that rotation ends before next event starts
|
||||
all_rotations_checked = True
|
||||
break
|
||||
last_start = start
|
||||
day = CustomOnCallShift.ICAL_WEEKDAY_MAP[start.weekday()]
|
||||
if (user_group_id, day, i) in combinations:
|
||||
all_rotations_checked = True
|
||||
break
|
||||
|
||||
starting_dates.append(start)
|
||||
combinations.append((user_group_id, day, i))
|
||||
# get next event date following the original rule
|
||||
event_ical = self.generate_ical(start, 1, None, 1, time_zone, custom_rrule=day_by_day_rrule)
|
||||
start = self.get_rotation_date(event_ical, get_next_date=True, interval=1)
|
||||
if all_rotations_checked:
|
||||
break
|
||||
|
||||
# number of weeks used to cover all combinations
|
||||
week_interval = ((last_start - orig_start).days // 7) or 1
|
||||
counter = 1
|
||||
for ((user_group_id, day, _), start) in zip(combinations, starting_dates):
|
||||
users = users_queue[user_group_id]
|
||||
for user_counter, user in enumerate(users, start=1):
|
||||
# setup weekly events, for each user group/day combinations,
|
||||
# setting the right interval and the corresponding day
|
||||
custom_rrule = copy.deepcopy(self.event_ical_rules)
|
||||
custom_rrule["freq"] = ["WEEKLY"]
|
||||
custom_rrule["interval"] = [week_interval]
|
||||
custom_rrule["byday"] = [day]
|
||||
custom_event_ical = self.generate_ical(
|
||||
start, user_counter, user, counter, time_zone, custom_rrule=custom_rrule
|
||||
)
|
||||
result += custom_event_ical
|
||||
counter += 1
|
||||
return result
|
||||
|
||||
def convert_to_ical(self, time_zone="UTC", allow_empty_users=False):
|
||||
result = ""
|
||||
# use shift time_zone if it exists, otherwise use schedule or default time_zone
|
||||
|
|
@ -299,6 +365,10 @@ class CustomOnCallShift(models.Model):
|
|||
else:
|
||||
start = self.get_rotation_date(event_ical)
|
||||
|
||||
if self.frequency == CustomOnCallShift.FREQUENCY_DAILY and self.by_day:
|
||||
result = self._daily_by_day_to_ical(time_zone, start, users_queue)
|
||||
all_rotation_checked = True
|
||||
|
||||
while not all_rotation_checked:
|
||||
for counter, users in enumerate(users_queue, start=1):
|
||||
if not start: # means that rotation ends before next event starts
|
||||
|
|
@ -325,7 +395,7 @@ class CustomOnCallShift(models.Model):
|
|||
result += self.generate_ical(self.start, user_counter, user, time_zone=time_zone)
|
||||
return result
|
||||
|
||||
def generate_ical(self, start, user_counter=0, user=None, counter=1, time_zone="UTC"):
|
||||
def generate_ical(self, start, user_counter=0, user=None, counter=1, time_zone="UTC", custom_rrule=None):
|
||||
event = Event()
|
||||
event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}"
|
||||
if user:
|
||||
|
|
@ -333,7 +403,9 @@ class CustomOnCallShift(models.Model):
|
|||
event.add("dtstart", self.convert_dt_to_schedule_timezone(start, time_zone))
|
||||
event.add("dtend", self.convert_dt_to_schedule_timezone(start + self.duration, time_zone))
|
||||
event.add("dtstamp", self.rotation_start)
|
||||
if self.event_ical_rules:
|
||||
if custom_rrule:
|
||||
event.add("rrule", custom_rrule)
|
||||
elif self.event_ical_rules:
|
||||
event.add("rrule", self.event_ical_rules)
|
||||
try:
|
||||
event_in_ical = event.to_ical().decode("utf-8")
|
||||
|
|
@ -349,7 +421,7 @@ class CustomOnCallShift(models.Model):
|
|||
summary += f"{user.username} "
|
||||
return summary
|
||||
|
||||
def get_rotation_date(self, event_ical, get_next_date=False):
|
||||
def get_rotation_date(self, event_ical, get_next_date=False, interval=None):
|
||||
"""Get date of the next event (for rolling_users shifts)"""
|
||||
ONE_DAY = 1
|
||||
ONE_HOUR = 1
|
||||
|
|
@ -363,7 +435,8 @@ class CustomOnCallShift(models.Model):
|
|||
|
||||
current_event = Event.from_ical(event_ical)
|
||||
# take shift interval, not event interval. For rolling_users shift it is not the same.
|
||||
interval = self.interval or 1
|
||||
if interval is None:
|
||||
interval = self.interval or 1
|
||||
if "rrule" in current_event:
|
||||
# when triggering shift previews, there could be no rrule information yet
|
||||
# (e.g. initial empty weekly rotation has no rrule set)
|
||||
|
|
|
|||
|
|
@ -336,13 +336,15 @@ class OnCallSchedule(PolymorphicModel):
|
|||
resolved.append(ev)
|
||||
continue
|
||||
|
||||
if ev["priority_level"] != current_priority:
|
||||
# api/terraform shifts could be missing a priority; assume None means 0
|
||||
priority = ev["priority_level"] or 0
|
||||
if priority != current_priority:
|
||||
# update scheduled intervals on priority change
|
||||
# and start from the beginning for the new priority level
|
||||
resolved.sort(key=event_start_cmp_key)
|
||||
intervals = _merge_intervals(resolved)
|
||||
current_interval_idx = 0
|
||||
current_priority = ev["priority_level"]
|
||||
current_priority = priority
|
||||
|
||||
if current_interval_idx >= len(intervals):
|
||||
# event outside scheduled intervals, add to resolved
|
||||
|
|
@ -406,8 +408,10 @@ class OnCallSchedule(PolymorphicModel):
|
|||
and current["shift"]["pk"] is not None
|
||||
and current["shift"]["pk"] == next_event["shift"]["pk"]
|
||||
):
|
||||
current["users"] += next_event["users"]
|
||||
current["missing_users"] += next_event["missing_users"]
|
||||
current["users"] += [u for u in next_event["users"] if u not in current["users"]]
|
||||
current["missing_users"] += [
|
||||
u for u in next_event["missing_users"] if u not in current["missing_users"]
|
||||
]
|
||||
else:
|
||||
merged.append(next_event)
|
||||
current = next_event
|
||||
|
|
|
|||
|
|
@ -293,6 +293,146 @@ def test_rolling_users_event_with_interval_daily(
|
|||
assert len(users_on_call) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rolling_users_event_daily_by_day(
|
||||
make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
):
|
||||
organization, user_1 = make_organization_and_user()
|
||||
user_2 = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_weekday = now.weekday()
|
||||
delta_days = (0 - today_weekday) % 7 + (7 if today_weekday == 0 else 0)
|
||||
next_week_monday = now + timezone.timedelta(days=delta_days)
|
||||
|
||||
# MO, WE, FR
|
||||
weekdays = [0, 2, 4]
|
||||
by_day = [CustomOnCallShift.ICAL_WEEKDAY_MAP[day] for day in weekdays]
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
"start": next_week_monday,
|
||||
"rotation_start": next_week_monday,
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"interval": 1,
|
||||
"by_day": by_day,
|
||||
"schedule": schedule,
|
||||
}
|
||||
rolling_users = [[user_1], [user_2]]
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users(rolling_users)
|
||||
|
||||
date = next_week_monday + timezone.timedelta(minutes=5)
|
||||
|
||||
user_1_on_call_dates = [date, date + timezone.timedelta(days=4), date + timezone.timedelta(days=9)]
|
||||
user_2_on_call_dates = [date + timezone.timedelta(days=2), date + timezone.timedelta(days=7)]
|
||||
nobody_on_call_dates = [
|
||||
date + timezone.timedelta(days=1), # TU
|
||||
date + timezone.timedelta(days=3), # TH
|
||||
date + timezone.timedelta(days=5), # SAT
|
||||
date + timezone.timedelta(days=6), # SUN
|
||||
date + timezone.timedelta(days=8), # TU
|
||||
date + timezone.timedelta(days=10), # TH
|
||||
date + timezone.timedelta(days=12), # SAT
|
||||
]
|
||||
|
||||
for dt in user_1_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 1
|
||||
assert user_1 in users_on_call
|
||||
|
||||
for dt in user_2_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 1
|
||||
assert user_2 in users_on_call
|
||||
|
||||
for dt in nobody_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rolling_users_event_with_interval_daily_by_day(
|
||||
make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
):
|
||||
organization, user_1 = make_organization_and_user()
|
||||
user_2 = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_weekday = now.weekday()
|
||||
delta_days = (0 - today_weekday) % 7 + (7 if today_weekday == 0 else 0)
|
||||
next_week_monday = now + timezone.timedelta(days=delta_days)
|
||||
|
||||
# MO, WE, FR
|
||||
weekdays = [0, 2, 4]
|
||||
by_day = [CustomOnCallShift.ICAL_WEEKDAY_MAP[day] for day in weekdays]
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
"start": next_week_monday,
|
||||
"rotation_start": next_week_monday,
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"interval": 2,
|
||||
"by_day": by_day,
|
||||
"schedule": schedule,
|
||||
}
|
||||
rolling_users = [[user_1], [user_2]]
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users(rolling_users)
|
||||
|
||||
date = next_week_monday + timezone.timedelta(minutes=5)
|
||||
|
||||
user_1_on_call_dates = [
|
||||
date, # MO
|
||||
date + timezone.timedelta(days=2), # WE
|
||||
date + timezone.timedelta(days=9), # WE
|
||||
date + timezone.timedelta(days=11), # FR
|
||||
date + timezone.timedelta(days=18), # FR
|
||||
date + timezone.timedelta(days=21), # MO
|
||||
date + timezone.timedelta(days=28), # MO
|
||||
date + timezone.timedelta(days=30), # WE
|
||||
]
|
||||
user_2_on_call_dates = [
|
||||
date + timezone.timedelta(days=4), # FR
|
||||
date + timezone.timedelta(days=7), # MO
|
||||
date + timezone.timedelta(days=14), # MO
|
||||
date + timezone.timedelta(days=16), # WE
|
||||
date + timezone.timedelta(days=23), # WE
|
||||
date + timezone.timedelta(days=25), # FR
|
||||
date + timezone.timedelta(days=32), # FR
|
||||
date + timezone.timedelta(days=35), # MO
|
||||
]
|
||||
nobody_on_call_dates = [
|
||||
date + timezone.timedelta(days=1), # TU
|
||||
date + timezone.timedelta(days=3), # TH
|
||||
date + timezone.timedelta(days=5), # SAT
|
||||
date + timezone.timedelta(days=6), # SUN
|
||||
date + timezone.timedelta(days=8), # TU
|
||||
date + timezone.timedelta(days=10), # TH
|
||||
date + timezone.timedelta(days=12), # SAT
|
||||
]
|
||||
|
||||
for dt in user_1_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 1
|
||||
assert user_1 in users_on_call
|
||||
|
||||
for dt in user_2_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 1
|
||||
assert user_2 in users_on_call
|
||||
|
||||
for dt in nobody_on_call_dates:
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, dt)
|
||||
assert len(users_on_call) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rolling_users_event_with_interval_weekly(
|
||||
make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
|
|
|
|||
|
|
@ -387,6 +387,86 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma
|
|||
assert returned_events == expected_events
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_final_schedule_override_no_priority_shift(
|
||||
make_organization, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
):
|
||||
organization = make_organization()
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
user_a, user_b = (make_user_for_organization(organization, username=i) for i in "AB")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
(user_a, 0, 10, 5), # 10-15 / A
|
||||
)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": start_date + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": start_date + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(hours=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
|
||||
# override: 10-15 / B
|
||||
override_data = {
|
||||
"start": start_date + timezone.timedelta(hours=10),
|
||||
"rotation_start": start_date + timezone.timedelta(hours=5),
|
||||
"duration": timezone.timedelta(hours=5),
|
||||
"schedule": schedule,
|
||||
}
|
||||
override = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
|
||||
)
|
||||
override.add_rolling_users([[user_b]])
|
||||
|
||||
returned_events = schedule.final_events("UTC", start_date, days=1)
|
||||
|
||||
expected = (
|
||||
# start (h), duration (H), user, priority, is_override
|
||||
(10, 5, "B", None, True), # 10-15 B
|
||||
)
|
||||
expected_events = [
|
||||
{
|
||||
"calendar_type": 1 if is_override else 0,
|
||||
"end": start_date + timezone.timedelta(hours=start + duration),
|
||||
"is_override": is_override,
|
||||
"priority_level": priority,
|
||||
"start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0),
|
||||
"user": user,
|
||||
}
|
||||
for start, duration, user, priority, is_override in expected
|
||||
]
|
||||
returned_events = [
|
||||
{
|
||||
"calendar_type": e["calendar_type"],
|
||||
"end": e["end"],
|
||||
"is_override": e["is_override"],
|
||||
"priority_level": e["priority_level"],
|
||||
"start": e["start"],
|
||||
"user": e["users"][0]["display_name"] if e["users"] else None,
|
||||
}
|
||||
for e in returned_events
|
||||
if not e["is_gap"]
|
||||
]
|
||||
assert returned_events == expected_events
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_final_schedule_splitting_events(
|
||||
make_organization, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
django==3.2.15
|
||||
django==3.2.16
|
||||
djangorestframework==3.12.4
|
||||
slackclient==1.3.0
|
||||
whitenoise==5.3.0
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.alerts.tasks.unsilence.unsilence_task": {"queue": "critical"},
|
||||
"apps.base.tasks.process_failed_to_invoke_celery_tasks": {"queue": "critical"},
|
||||
"apps.base.tasks.process_failed_to_invoke_celery_tasks_batch": {"queue": "critical"},
|
||||
"apps.email.tasks.notify_user_async": {"queue": "critical"},
|
||||
"apps.integrations.tasks.create_alert": {"queue": "critical"},
|
||||
"apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"},
|
||||
"apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"},
|
||||
|
|
|
|||
15
grafana-plugin/babel.config.json
Normal file
15
grafana-plugin/babel.config.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "node": "current" } }],
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
["@babel/plugin-transform-destructuring", { "useBuiltIns": true }],
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/proposal-class-properties",
|
||||
"@babel/transform-regenerator",
|
||||
"@babel/plugin-transform-template-literals",
|
||||
]
|
||||
}
|
||||
|
|
@ -1,29 +1,19 @@
|
|||
const esModules = ['react-colorful', 'uuid', 'ol'].join('|');
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
moduleDirectories: ['node_modules', 'src'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js'],
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
isolatedModules: true,
|
||||
babelConfig: true
|
||||
},
|
||||
},
|
||||
|
||||
transform: {
|
||||
'^.+\\.js?$': require.resolve('babel-jest'),
|
||||
'^.+\\.jsx?$': require.resolve('babel-jest'),
|
||||
'^.+\\.ts?$': require.resolve('ts-jest'),
|
||||
'^.+\\.tsx?$': require.resolve('ts-jest'),
|
||||
},
|
||||
transformIgnorePatterns: [`/node_modules/(?!${esModules})`],
|
||||
|
||||
moduleNameMapper: {
|
||||
"grafana/app/(.*)": '<rootDir>/src/jest/grafanaMock.ts',
|
||||
"jest/outgoingWebhooksStub": '<rootDir>/src/jest/outgoingWebhooksStub.ts',
|
||||
"^jest$": '<rootDir>/src/jest',
|
||||
'grafana/app/(.*)': '<rootDir>/src/jest/grafanaMock.ts',
|
||||
'jest/matchMedia': '<rootDir>/src/jest/matchMedia.ts',
|
||||
'jest/outgoingWebhooksStub': '<rootDir>/src/jest/outgoingWebhooksStub.ts',
|
||||
'^jest$': '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
"^lodash-es$": "lodash",
|
||||
}
|
||||
};
|
||||
'^lodash-es$': 'lodash',
|
||||
},
|
||||
};
|
||||
|
|
@ -40,27 +40,29 @@
|
|||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.18.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.20.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.18.9",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.18.9",
|
||||
"@babel/plugin-syntax-decorators": "^7.18.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-destructuring": "^7.20.0",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.18.12",
|
||||
"@babel/plugin-transform-runtime": "^7.19.6",
|
||||
"@babel/plugin-transform-typescript": "^7.18.12",
|
||||
"@babel/preset-env": "^7.18.10",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@grafana/data": "^9.1.1",
|
||||
"@grafana/data": "9.1.1",
|
||||
"@grafana/eslint-config": "^5.0.0",
|
||||
"@grafana/runtime": "^9.1.1",
|
||||
"@grafana/toolkit": "^9.1.1",
|
||||
"@grafana/ui": "^9.1.1",
|
||||
"@jest/globals": "^27.5.1",
|
||||
"@grafana/runtime": "9.1.1",
|
||||
"@grafana/toolkit": "9.1.1",
|
||||
"@grafana/ui": "9.1.1",
|
||||
"@jest/globals": "27.5.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "12",
|
||||
"@types/dompurify": "^2.3.4",
|
||||
"@types/jest": "^27.5.1",
|
||||
"@types/jest": "27.5.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
|
|
@ -69,6 +71,7 @@
|
|||
"@types/react-test-renderer": "^17.0.2",
|
||||
"@types/throttle-debounce": "^5.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"dompurify": "^2.3.12",
|
||||
"eslint": "^8.25.0",
|
||||
|
|
@ -76,17 +79,19 @@
|
|||
"eslint-plugin-react": "^7.31.10",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-rulesdir": "^0.2.1",
|
||||
"jest": "^27.5.1",
|
||||
"jest": "27.5.1",
|
||||
"jest-environment-jsdom": "^27.5.1",
|
||||
"lint-staged": "^10.2.11",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment-timezone": "^0.5.35",
|
||||
"plop": "^2.7.4",
|
||||
"postcss-loader": "^7.0.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-prettier": "^2.0.0",
|
||||
"ts-jest": "^27.1.3",
|
||||
"ts-jest": "29.0.3",
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": "4.6.4",
|
||||
"webpack-bundle-analyzer": "^4.6.1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
import { PENDING_COLOR, Tooltip, Icon } from '@grafana/ui';
|
||||
|
||||
import { Schedule } from 'models/schedule/schedule.types';
|
||||
|
||||
interface ScheduleWarningProps {
|
||||
item: Schedule;
|
||||
}
|
||||
|
||||
const ScheduleWarning = (props: ScheduleWarningProps) => {
|
||||
const { item } = props;
|
||||
if (item.warnings.length > 0) {
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
{item.warnings.map((warning: string, key: number) => (
|
||||
<p key={key}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip placement="top" content={tooltipContent}>
|
||||
<Icon style={{ color: PENDING_COLOR }} name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScheduleWarning;
|
||||
|
|
@ -68,9 +68,8 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => {
|
|||
</Field>
|
||||
<Field label="Type">
|
||||
<RadioButtonGroup
|
||||
disabled
|
||||
options={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'All', value: undefined },
|
||||
{
|
||||
label: 'Web',
|
||||
value: ScheduleType.API,
|
||||
|
|
@ -84,7 +83,7 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => {
|
|||
value: ScheduleType.Calendar,
|
||||
},
|
||||
]}
|
||||
value={value.type}
|
||||
value={value?.type}
|
||||
onChange={handleTypeChange}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
rolling_users: userGroups,
|
||||
interval: repeatEveryValue,
|
||||
frequency: repeatEveryPeriod,
|
||||
by_day: repeatEveryPeriod === 1 ? selectedDays : null,
|
||||
by_day: repeatEveryPeriod === 1 || repeatEveryPeriod === 0 ? selectedDays : null,
|
||||
priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level,
|
||||
}),
|
||||
[
|
||||
|
|
@ -320,7 +320,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
{repeatEveryPeriod === 1 && (
|
||||
{(repeatEveryPeriod === 1 || repeatEveryPeriod === 0) && (
|
||||
<Field label="Select days to repeat">
|
||||
<DaysSelector
|
||||
options={store.scheduleStore.byDayOptions}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { Rotation, RotationType, Schedule, ScheduleEvent, Shift, Event, Layer, S
|
|||
|
||||
export class ScheduleStore extends BaseStore {
|
||||
@observable
|
||||
searchResult: { [key: string]: Array<Schedule['id']> } = {};
|
||||
searchResult: { results?: Array<Schedule['id']> } = {};
|
||||
|
||||
@observable.shallow
|
||||
items: { [id: string]: Schedule } = {};
|
||||
|
|
@ -105,8 +105,11 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
async updateItems(query = '') {
|
||||
const result = await makeRequest(this.path, { method: 'GET', params: { search: query } });
|
||||
async updateItems(f: any = { searchTerm: '', type: undefined }) {
|
||||
// async updateItems(query = '') {
|
||||
const filters = typeof f === 'string' ? { searchTerm: f } : f;
|
||||
const { searchTerm: search, type } = filters;
|
||||
const result = await makeRequest(this.path, { method: 'GET', params: { search: search, type } });
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
|
|
@ -118,10 +121,9 @@ export class ScheduleStore extends BaseStore {
|
|||
{}
|
||||
),
|
||||
};
|
||||
|
||||
this.searchResult = {
|
||||
...this.searchResult,
|
||||
[query]: result.map((item: Schedule) => item.id),
|
||||
results: result.map((item: Schedule) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -136,12 +138,11 @@ export class ScheduleStore extends BaseStore {
|
|||
}
|
||||
}
|
||||
|
||||
getSearchResult(query = '') {
|
||||
if (!this.searchResult[query]) {
|
||||
getSearchResult() {
|
||||
if (!this.searchResult.results) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.searchResult[query].map((scheduleId: Schedule['id']) => this.items[scheduleId]);
|
||||
return this.searchResult?.results?.map((scheduleId: Schedule['id']) => this.items[scheduleId]);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import '@testing-library/jest-dom';
|
|||
import outgoingWebhooksStub from 'jest/outgoingWebhooksStub';
|
||||
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
|
||||
import { OutgoingWebhooks } from './OutgoingWebhooks';
|
||||
import { OutgoingWebhooks } from 'pages/outgoing_webhooks/OutgoingWebhooks';
|
||||
|
||||
const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[];
|
||||
const outgoingWebhookStore = () => ({
|
||||
|
|
@ -21,12 +20,21 @@ const outgoingWebhookStore = () => ({
|
|||
}, {}),
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
featureToggles: {
|
||||
topNav: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('state/useStore', () => ({
|
||||
useStore: () => ({
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { omit } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import Text from 'components/Text/Text';
|
||||
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
|
|
@ -15,8 +17,9 @@ import Rotations from 'containers/Rotations/Rotations';
|
|||
import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
|
||||
import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides';
|
||||
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
|
||||
import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
|
||||
import UsersTimezones from 'containers/UsersTimezones/UsersTimezones';
|
||||
import { Shift } from 'models/schedule/schedule.types';
|
||||
import { Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -24,7 +27,6 @@ import { withMobXProviderContext } from 'state/withStore';
|
|||
import { getStartOfWeek } from './Schedule.helpers';
|
||||
|
||||
import styles from './Schedule.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SchedulePageProps extends AppRootProps, WithStoreProps {}
|
||||
|
|
@ -37,6 +39,7 @@ interface SchedulePageState {
|
|||
shiftIdToShowOverridesForm?: Shift['id'];
|
||||
isLoading: boolean;
|
||||
showEditForm: boolean;
|
||||
showScheduleICalSettings: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -53,6 +56,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
shiftIdToShowOverridesForm: undefined,
|
||||
isLoading: true,
|
||||
showEditForm: false,
|
||||
showScheduleICalSettings: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +93,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
shiftIdToShowRotationForm,
|
||||
shiftIdToShowOverridesForm,
|
||||
showEditForm,
|
||||
showScheduleICalSettings,
|
||||
} = this.state;
|
||||
|
||||
const { scheduleStore, currentTimezone } = store;
|
||||
|
|
@ -109,6 +114,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
|
|
@ -118,6 +124,16 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
{schedule?.type === ScheduleType.Ical && (
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={this.handleExportClick()}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
|
||||
Reload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon="cog"
|
||||
tooltip="Settings"
|
||||
|
|
@ -206,6 +222,16 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{showScheduleICalSettings && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Schedule export"
|
||||
closeOnEscape
|
||||
onDismiss={() => this.setState({ showScheduleICalSettings: false })}
|
||||
>
|
||||
<ScheduleICalSettings id={scheduleId} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -371,6 +397,47 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
this.setState({ startMoment: startMoment.add(7, 'day') }, this.handleDateRangeUpdate);
|
||||
};
|
||||
|
||||
handleExportClick = () => {
|
||||
return () => {
|
||||
this.setState({ showScheduleICalSettings: true });
|
||||
};
|
||||
};
|
||||
|
||||
handleReloadClick = (scheduleId: Schedule['id']) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
return async () => {
|
||||
await scheduleStore.reloadIcal(scheduleId);
|
||||
|
||||
scheduleStore.updateItem(scheduleId);
|
||||
this.updateEventsFor(scheduleId);
|
||||
};
|
||||
};
|
||||
|
||||
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
const { scheduleStore } = store;
|
||||
|
||||
store.scheduleStore.scheduleToScheduleEvents = omit(store.scheduleStore.scheduleToScheduleEvents, [scheduleId]);
|
||||
|
||||
await scheduleStore.updateScheduleEvents(
|
||||
scheduleId,
|
||||
withEmpty,
|
||||
with_gap,
|
||||
dayjs().format('YYYY-MM-DD').toString(),
|
||||
dayjs.tz.guess()
|
||||
);
|
||||
|
||||
await store.scheduleStore.updateOncallShifts(id);
|
||||
await this.updateEvents();
|
||||
};
|
||||
|
||||
handleDelete = () => {
|
||||
const {
|
||||
store,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Avatar from 'components/Avatar/Avatar';
|
|||
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import SchedulesFilters from 'components/SchedulesFilters_NEW/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters_NEW/SchedulesFilters.types';
|
||||
import Table from 'components/Table/Table';
|
||||
|
|
@ -51,7 +52,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
const { store } = this.props;
|
||||
this.state = {
|
||||
startMoment: getStartOfWeek(store.currentTimezone),
|
||||
filters: { searchTerm: '', status: 'all', type: ScheduleType.API },
|
||||
filters: { searchTerm: '', status: 'all', type: undefined },
|
||||
showNewScheduleSelector: false,
|
||||
expandedRowKeys: [],
|
||||
scheduleIdToEdit: undefined,
|
||||
|
|
@ -80,10 +81,10 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
render: this.renderType,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
width: '5%',
|
||||
title: 'Status',
|
||||
key: 'name',
|
||||
render: this.renderStatus,
|
||||
render: (item: Schedule) => this.renderStatus(item),
|
||||
},
|
||||
{
|
||||
width: '30%',
|
||||
|
|
@ -107,6 +108,11 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
title: 'Slack user group',
|
||||
render: this.renderUserGroup,
|
||||
},
|
||||
{
|
||||
width: '5%',
|
||||
key: 'warning',
|
||||
render: this.renderWarning,
|
||||
},
|
||||
{
|
||||
width: '50px',
|
||||
key: 'buttons',
|
||||
|
|
@ -119,7 +125,6 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
|
||||
const data = schedules
|
||||
? schedules
|
||||
.filter((schedule) => schedule.type === ScheduleType.API)
|
||||
.filter(
|
||||
(schedule) =>
|
||||
filters.status === 'all' ||
|
||||
|
|
@ -265,38 +270,52 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
return typeToVerbal[value];
|
||||
};
|
||||
|
||||
renderWarning = (item: Schedule) => {
|
||||
return <ScheduleWarning item={item} />;
|
||||
};
|
||||
|
||||
renderStatus = (item: Schedule) => {
|
||||
const {
|
||||
store: { scheduleStore },
|
||||
} = this.props;
|
||||
|
||||
const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id];
|
||||
|
||||
return (
|
||||
<HorizontalGroup>
|
||||
<ScheduleCounter
|
||||
type="link"
|
||||
count={item.number_of_escalation_chains}
|
||||
tooltipTitle="Used in escalations"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{relatedEscalationChains ? (
|
||||
relatedEscalationChains.length ? (
|
||||
relatedEscalationChains.map((escalationChain) => (
|
||||
<PluginLink key={escalationChain.pk} query={{ page: 'escalations', id: escalationChain.pk }}>
|
||||
{escalationChain.name}
|
||||
</PluginLink>
|
||||
))
|
||||
{item.number_of_escalation_chains > 0 && (
|
||||
<ScheduleCounter
|
||||
type="link"
|
||||
count={item.number_of_escalation_chains}
|
||||
tooltipTitle="Used in escalations"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{relatedEscalationChains ? (
|
||||
relatedEscalationChains.length ? (
|
||||
relatedEscalationChains.map((escalationChain) => (
|
||||
<div key={escalationChain.pk}>
|
||||
<PluginLink query={{ page: 'escalations', id: escalationChain.pk }}>
|
||||
{escalationChain.name}
|
||||
</PluginLink>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
'Not used yet'
|
||||
)
|
||||
) : (
|
||||
'Not used yet'
|
||||
)
|
||||
) : (
|
||||
<LoadingPlaceholder>Loading related escalation chains....</LoadingPlaceholder>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
}
|
||||
onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)}
|
||||
/>
|
||||
<LoadingPlaceholder>Loading related escalation chains....</LoadingPlaceholder>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
}
|
||||
onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <ScheduleCounter
|
||||
type="warning"
|
||||
count={warningsCount}
|
||||
tooltipTitle="Warnings"
|
||||
tooltipContent="Schedule has unassigned time periods during next 7 days"
|
||||
/>*/}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -372,9 +391,10 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
};
|
||||
|
||||
applyFilters = () => {
|
||||
// const { filters } = this.state;
|
||||
// const { scheduleStore } = this.props.store;
|
||||
// scheduleStore.updateItems(filters.searchTerm);
|
||||
const { filters } = this.state;
|
||||
const { store } = this.props;
|
||||
const { scheduleStore } = store;
|
||||
scheduleStore.updateItems(filters);
|
||||
};
|
||||
|
||||
debouncedUpdateSchedules = debounce(this.applyFilters, 1000);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,13 +8,13 @@ type: application
|
|||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.0.8
|
||||
version: 1.0.9
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "v1.0.49"
|
||||
appVersion: "v1.0.50"
|
||||
dependencies:
|
||||
- name: cert-manager
|
||||
version: v1.8.0
|
||||
|
|
|
|||
|
|
@ -237,15 +237,15 @@
|
|||
|
||||
{{- define "snippet.rabbitmq.env" -}}
|
||||
{{- if eq .Values.broker.type "rabbitmq" -}}
|
||||
{{- if and (not .Values.rabbitmq.enabled) (not .Values.externalRabbitmq.existingSecret) (not .Values.externalRabbitmq.usernameKey) .Values.externalRabbitmq.user }}
|
||||
- name: RABBITMQ_USERNAME
|
||||
value: {{ include "snippet.rabbitmq.user" . }}
|
||||
{{- else if and (not .Values.rabbitmq.enabled) .Values.externalRabbitmq.existingSecret .Values.externalRabbitmq.usernameKey (not .Values.externalRabbitmq.user) }}
|
||||
{{- if and (not .Values.rabbitmq.enabled) .Values.externalRabbitmq.existingSecret .Values.externalRabbitmq.usernameKey (not .Values.externalRabbitmq.user) }}
|
||||
- name: RABBITMQ_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "snippet.rabbitmq.password.secret.name" . }}
|
||||
key: {{ .Values.externalRabbitmq.usernameKey }}
|
||||
{{- else }}
|
||||
- name: RABBITMQ_USERNAME
|
||||
value: {{ include "snippet.rabbitmq.user" . }}
|
||||
{{- end }}
|
||||
- name: RABBITMQ_PASSWORD
|
||||
valueFrom:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue