Merge pull request #2955 from grafana/dev

v1.3.31
This commit is contained in:
Yulya Artyukhina 2023-09-04 14:30:07 +02:00 committed by GitHub
commit 910db5dd15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1516 additions and 728 deletions

View file

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## v1.3.31 (2023-09-04)
### Fixed
- Fix for Cloud plugin install not refreshing page after completion ([2974](https://github.com/grafana/oncall/issues/2874))
- Fix escalation snapshot building if user was deleted @Ferril ([#2954](https://github.com/grafana/oncall/pull/2954))
## v1.3.30 (2023-08-31)
### Added
@ -15,16 +22,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Update Shift Swap Request Slack message formatting by @joeyorlando ([#2918](https://github.com/grafana/oncall/pull/2918))
- Performance and UX tweaks to integrations page ([#2869](https://github.com/grafana/oncall/pull/2869))
- Expand users details in filter swaps internal endpoint ([#2921](https://github.com/grafana/oncall/pull/2921))
- Truncate exported final shifts to match the requested period ([#2924](https://github.com/grafana/oncall/pull/2924))
### Fixed
- Fix issue with helm chart when specifying `broker.type=rabbitmq` where Redis environment variables
were not longer being injected @joeyorlando ([#2927](https://github.com/grafana/oncall/pull/2927))
were not longer being injected by @joeyorlando ([#2927](https://github.com/grafana/oncall/pull/2927))
- Fix silence for alert groups with empty escalation chain @Ferril ([#2929](https://github.com/grafana/oncall/pull/2929))
- Fixed NPE when migrating legacy Grafana Alerting integrations
([#2908](https://github.com/grafana/oncall/issues/2908))
- Fixed NPE when migrating legacy Grafana Alerting integrations ([#2908](https://github.com/grafana/oncall/issues/2908))
- Fix `IntegrityError` exceptions that occasionally would occur when trying to create `ResolutionNoteSlackMessage`
objects by @joeyorlando ([#2933](https://github.com/grafana/oncall/pull/2933))
## v1.3.29 (2023-08-29)
@ -39,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Switch engine to alpine base image ([2872](https://github.com/grafana/oncall/pull/2872))
### Added
- Visualization of shift swap requests in Overrides and swaps section ([#2844](https://github.com/grafana/oncall/issues/2844))
### Fixed
- Address bug when a Shift Swap Request is accepted either via the web or mobile UI, and the Slack message is not

View file

@ -6,6 +6,13 @@
# [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes.
# Changes are relevant to this script and the support docs.mk GNU Make interface.
# ## 4.2.0 (2023-09-01)
# ### Added
# - Retry the initial webserver request up to ten times to allow for the process to start.
# If it is still failing after ten seconds, an error message is logged.
# ## 4.1.1 (2023-07-20)
# ### Fixed
@ -439,30 +446,39 @@ await_build() {
url="$1"
req="$(if command -v curl >/dev/null 2>&1; then echo 'curl -s -o /dev/null'; else echo 'wget -q'; fi)"
sleep 2
i=1
max=10
while [ "${i}" -ne "${max}" ]
do
sleep 1
debg "Retrying request to webserver assuming the process is still starting up."
i=$((i + 1))
if ${req} "${url}"; then
echo
echo "View documentation locally:"
for x in ${url_src_dst_vers}; do
IFS='^' read -r url _ _ <<POSIX_HERESTRING
if ${req} "${url}"; then
echo
echo "View documentation locally:"
for x in ${url_src_dst_vers}; do
IFS='^' read -r url _ _ <<POSIX_HERESTRING
$x
POSIX_HERESTRING
if [ -n "${url}" ]; then
if [ "${_url}" != "arbitrary" ]; then
echo " ${url}"
if [ -n "${url}" ]; then
if [ "${_url}" != "arbitrary" ]; then
echo " ${url}"
fi
fi
fi
done
echo
echo 'Press Ctrl+C to stop the server'
else
echo
errr 'The build was interrupted or a build error occurred, check the previous logs for possible causes.'
fi
done
echo
echo 'Press Ctrl+C to stop the server'
unset url req
unset i max req url
return
fi
done
echo
errr 'The build was interrupted or a build error occurred, check the previous logs for possible causes.'
unset i max req url
}
debg() {

View file

@ -36,14 +36,14 @@ of a schedule, or clicking the button shown when hovering on a particular shift
<img src="/static/img/oncall/swap-web-hover.png">
<img src="/static/img/oncall/swap-web-request.png">
>**Note**: no recurrence rules support is available when requesting a shift swap. If you need to recurrently change a shift,
consider creating a higher level layer rotation with the desired updates.
> **Note**: no recurrence rules support is available when requesting a shift swap. If you need to recurrently change a shift,
> consider creating a higher level layer rotation with the desired updates.
Upon submitting the request, a Slack notification will be sent to the channel associated to the correspondent
schedule, if there is one. A [mobile push notification][shift-swap-notifications] will be sent to team members who
participate in the schedule and have the notifications enabled.
<img src="/static/img/oncall/swap-slack-notification.png">
<img src="/static/img/oncall/swap-slack-notification-3.png">
Push notifications are sent 4 weeks ahead of the requested shift swap, or shortly after creation in case
the shift swap start time is less than 4 weeks away, but always during users' working hours (by default 9am-5pm on
@ -64,8 +64,8 @@ The follow-up notifications will be sent at the following intervals before the s
You can delete the swap request at any time. If the swap has been taken, it will automatically be undone upon removal.
>**Note**: if [RBAC][rbac] is enabled, a user is required to have the `SCHEDULES_WRITE` permission to create,
update, take or delete a swap request. `SCHEDULES_READ` will be enough to get details about existing requests.
> **Note**: if [RBAC][rbac] is enabled, a user is required to have the `SCHEDULES_WRITE` permission to create,
> update, take or delete a swap request. `SCHEDULES_READ` will be enough to get details about existing requests.
## Check existing swap requests

View file

@ -8,7 +8,6 @@ weight: 1500
This endpoint retrieves the user object.
````shell
```shell
curl "{{API_URL}}/api/v1/users/current/" \
--request GET \

View file

@ -64,6 +64,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer):
num_alerts_in_window = serializers.IntegerField(allow_null=True, default=None)
num_minutes_in_window = serializers.IntegerField(allow_null=True, default=None)
pause_escalation = serializers.BooleanField(default=False)
last_notified_user = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=User.objects)
class Meta:
model = EscalationPolicy

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.20 on 2023-08-31 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0030_auto_20230731_0341'),
]
operations = [
migrations.AddIndex(
model_name='resolutionnoteslackmessage',
index=models.Index(fields=['ts', 'thread_ts', 'alert_group_id'], name='alerts_reso_ts_08f72c_idx'),
),
migrations.AddIndex(
model_name='resolutionnoteslackmessage',
index=models.Index(fields=['ts', 'thread_ts', 'slack_channel_id'], name='alerts_reso_ts_a9bdf7_idx'),
),
]

View file

@ -83,6 +83,11 @@ class ResolutionNoteSlackMessage(models.Model):
class Meta:
unique_together = ("thread_ts", "ts")
indexes = [
models.Index(fields=["ts", "thread_ts", "alert_group_id"]),
models.Index(fields=["ts", "thread_ts", "slack_channel_id"]),
]
def get_resolution_note(self) -> typing.Optional["ResolutionNote"]:
try:
return self.resolution_note

View file

@ -4,8 +4,6 @@ from .alert_group_web_title_cache import ( # noqa:F401
update_web_title_cache_for_alert_receive_channel,
)
from .check_escalation_finished import check_escalation_finished_task # noqa: F401
from .create_contact_points_for_datasource import create_contact_points_for_datasource # noqa: F401
from .create_contact_points_for_datasource import schedule_create_contact_points_for_datasource # noqa: F401
from .custom_button_result import custom_button_result # noqa: F401
from .custom_webhook_result import custom_webhook_result # noqa: F401
from .delete_alert_group import delete_alert_group # noqa: F401
@ -22,9 +20,6 @@ from .resolve_by_last_step import resolve_by_last_step_task # noqa: F401
from .send_alert_group_signal import send_alert_group_signal # noqa: F401
from .send_update_log_report_signal import send_update_log_report_signal # noqa: F401
from .send_update_resolution_note_signal import send_update_resolution_note_signal # noqa: F401
from .sync_grafana_alerting_contact_points import ( # noqa: F401
disconnect_integration_from_alerting_contact_points,
sync_grafana_alerting_contact_points,
)
from .sync_grafana_alerting_contact_points import disconnect_integration_from_alerting_contact_points # noqa: F401
from .unsilence import unsilence_task # noqa: F401
from .wipe import wipe # noqa: F401

View file

@ -81,36 +81,19 @@ def audit_alert_group_escalation(alert_group: "AlertGroup") -> None:
f"{base_msg}'s escalation snapshot has {num_of_executed_escalation_policy_snapshots} executed escalation policies"
)
# TODO: consider adding the below checks later on. This is it a bit trickier to properly audit as the
# number of log records can vary if there are any STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW or
# STEP_REPEAT_ESCALATION_N_TIMES escalation policy steps in the escalation chain
# see conversations in the original PR (https://github.com/grafana/oncall/pull/1266) for more context on this
#
# compare number of triggered/failed alert group log records to the number of executed
# escalation policy snapshot steps
# num_of_relevant_log_records = AlertGroupLogRecord.objects.filter(
# alert_group_id=alert_group_id,
# type__in=[AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED, AlertGroupLogRecord.TYPE_ESCALATION_FAILED],
# ).count()
# if num_of_relevant_log_records < num_of_executed_escalation_policy_snapshots:
# raise AlertGroupEscalationPolicyExecutionAuditException(
# f"{base_msg}'s number of triggered/failed alert group log records ({num_of_relevant_log_records}) is less "
# f"than the number of executed escalation policy snapshot steps ({num_of_executed_escalation_policy_snapshots})"
# )
# task_logger.info(
# f"{base_msg}'s number of triggered/failed alert group log records ({num_of_relevant_log_records}) is greater "
# f"than or equal to the number of executed escalation policy snapshot steps ({num_of_executed_escalation_policy_snapshots})"
# )
task_logger.info(f"{base_msg} passed the audit checks")
@shared_task
def check_escalation_finished_task() -> None:
"""
don't retry this task, the idea is to be alerted of failures
This task takes alert groups with active escalation, checks if escalation snapshot with escalation policies
was created and next escalation step eta is higher than now minus 5 min for every active alert group,
what means that escalations are going as expected.
If there are alert groups that failed the check, it raises exception. Otherwise - send heartbeat. Missing heartbeat
raises alert.
Attention: don't retry this task, the idea is to be alerted of failures
"""
from apps.alerts.models import AlertGroup

View file

@ -1,109 +0,0 @@
import logging
from celery.utils.log import get_task_logger
from django.core.cache import cache
from rest_framework import status
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
def get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id):
CACHE_KEY_PREFIX = "create_contact_points_for_datasource"
return f"{CACHE_KEY_PREFIX}_{alert_receive_channel_id}"
def set_cache_key_create_contact_points_for_datasource(alert_receive_channel_id, task_id):
CACHE_LIFETIME = 600
cache_key = get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id)
cache.set(cache_key, task_id, timeout=CACHE_LIFETIME)
@shared_dedicated_queue_retry_task
def schedule_create_contact_points_for_datasource(alert_receive_channel_id, datasource_list):
START_TASK_DELAY = 3
task = create_contact_points_for_datasource.apply_async(
args=[alert_receive_channel_id, datasource_list], countdown=START_TASK_DELAY
)
set_cache_key_create_contact_points_for_datasource(alert_receive_channel_id, task.id)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=20)
def create_contact_points_for_datasource(alert_receive_channel_id, datasource_list):
"""
Try to create contact points for other datasource.
Restart task for datasource, for which contact point was not created.
"""
cache_key = get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id)
cached_task_id = cache.get(cache_key)
current_task_id = create_contact_points_for_datasource.request.id
if cached_task_id is not None and current_task_id != cached_task_id:
return
from apps.alerts.models import AlertReceiveChannel
alert_receive_channel = AlertReceiveChannel.objects.filter(pk=alert_receive_channel_id).first()
if not alert_receive_channel:
logger.debug(
f"Create CP task: Cannot create contact point for integration {alert_receive_channel_id}: "
f"integration does not exist"
)
return
grafana_alerting_sync_manager = alert_receive_channel.grafana_alerting_sync_manager
logger.debug(
f"Create CP task: Create contact points for integration {alert_receive_channel_id}, "
f"retry counter: {create_contact_points_for_datasource.request.retries}, datasource list {len(datasource_list)}"
)
# list of datasource for which contact point creation was failed
datasources_to_create = []
for datasource in datasource_list:
datasource_type = datasource.get("type")
logger.debug(
f"Create CP task: Create contact point for datasource {datasource_type} "
f"for integration {alert_receive_channel_id}"
)
contact_point, response_info = grafana_alerting_sync_manager.create_contact_point(datasource)
if contact_point is None:
if response_info.get("status_code") == status.HTTP_400_BAD_REQUEST:
logger.warning(
f"Create CP task: Failed to create contact point for integration {alert_receive_channel_id}, "
f"datasource info: {datasource}; response: {response_info}. "
f"Got 400 Bad Request, exclude from retry list."
)
continue
logger.warning(
f"Create CP task: Failed to create contact point for integration {alert_receive_channel_id}, "
f"datasource info: {datasource}; response: {response_info}. Retrying"
)
# Failed to create contact point. Add datasource to list and retry to create contact point for it again
datasources_to_create.append(datasource)
# if some contact points were not created, restart task for them
if (
datasources_to_create
and create_contact_points_for_datasource.request.retries < create_contact_points_for_datasource.max_retries
):
logger.debug(
f"Create CP task: Retry to create contact points for integration {alert_receive_channel_id}, "
f"retry counter: {create_contact_points_for_datasource.request.retries}, "
f"datasource list {len(datasources_to_create)}"
)
# Save task id in cache and restart the task
set_cache_key_create_contact_points_for_datasource(alert_receive_channel_id, current_task_id)
create_contact_points_for_datasource.retry(args=(alert_receive_channel_id, datasources_to_create), countdown=3)
else:
alert_receive_channel.is_finished_alerting_setup = True
alert_receive_channel.save(update_fields=["is_finished_alerting_setup"])
logger.debug(
f"Create CP task: Alerting setup for integration {alert_receive_channel_id} is finished, "
f"retry counter: {create_contact_points_for_datasource.request.retries}, "
f"datasource list {len(datasource_list)}"
)
logger.debug(
f"Create CP task: Finished task to create contact points for integration {alert_receive_channel_id}, "
f"datasource list {len(datasource_list)}"
)

View file

@ -1,12 +1,6 @@
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
# deprecated
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=10)
def sync_grafana_alerting_contact_points(alert_receive_channel_id):
pass
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=10)
def disconnect_integration_from_alerting_contact_points(alert_receive_channel_id):
from apps.alerts.models import AlertReceiveChannel

View file

@ -266,3 +266,55 @@ def test_escalation_snapshot_non_sequential_orders(
policy_ids = [p.id for p in escalation_snapshot.executed_escalation_policy_snapshots]
assert policy_ids == [step_1.id, step_2.id]
@pytest.mark.django_db
def test_serialize_escalation_snapshot_with_deleted_user(
make_organization_and_user,
make_user_for_organization,
make_alert_receive_channel,
make_channel_filter,
make_escalation_chain,
make_escalation_policy,
make_alert_group,
):
organization, user = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
escalation_chain = make_escalation_chain(organization)
channel_filter = make_channel_filter(
alert_receive_channel,
escalation_chain=escalation_chain,
notification_backends={"BACKEND": {"channel_id": "abc123"}},
)
notify_users_queue = make_escalation_policy(
escalation_chain=channel_filter.escalation_chain,
escalation_policy_step=EscalationPolicy.STEP_NOTIFY_USERS_QUEUE,
last_notified_user=user,
)
notify_users_queue.notify_to_users_queue.set([user])
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
alert_group.save()
escalation_snapshot = alert_group.escalation_snapshot
assert notify_users_queue.last_notified_user == user
assert escalation_snapshot.escalation_policies_snapshots[0].last_notified_user == user
assert len(escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue) == 1
# delete user
user.is_active = None
user.save()
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
# clear cached_property
del alert_group.escalation_snapshot
alert_group.save()
escalation_snapshot = alert_group.escalation_snapshot
assert notify_users_queue.last_notified_user == user
assert escalation_snapshot is not None
assert escalation_snapshot.escalation_policies_snapshots[0].last_notified_user is None
assert len(escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue) == 0

View file

@ -846,6 +846,9 @@ def test_escalation_policy_filter_by_user(
assert response.status_code == status.HTTP_200_OK
result = response.json()
assert set(result[1]["notify_to_users_queue"]) == {user.public_primary_key, second_user.public_primary_key}
expected_payload[1]["notify_to_users_queue"] = result[1]["notify_to_users_queue"]
assert response.json() == expected_payload

View file

@ -2,7 +2,7 @@ from .going_oncall_notification import ( # noqa:F401
conditionally_send_going_oncall_push_notifications_for_all_schedules,
conditionally_send_going_oncall_push_notifications_for_schedule,
)
from .new_alert_group import notify_user_about_new_alert_group, notify_user_async # noqa:F401
from .new_alert_group import notify_user_about_new_alert_group # noqa:F401
from .new_shift_swap_request import ( # noqa:F401
notify_shift_swap_request,
notify_shift_swap_requests,

View file

@ -137,9 +137,3 @@ def notify_user_about_new_alert_group(user_pk, alert_group_pk, notification_poli
message = _get_fcm_message(alert_group, user, device_to_notify, critical)
send_push_notification(device_to_notify, message, _create_error_log_record)
# TODO: remove this in a future release
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical):
notify_user_about_new_alert_group(user_pk, alert_group_pk, notification_policy_pk, critical)

View file

@ -969,7 +969,7 @@ def test_oncall_shifts_export_from_ical_schedule(
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-08-01", format="json", HTTP_AUTHORIZATION=token)
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {
@ -1006,7 +1006,7 @@ def test_oncall_shifts_export_from_api_schedule(
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-08-01", format="json", HTTP_AUTHORIZATION=token)
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {
@ -1014,3 +1014,43 @@ def test_oncall_shifts_export_from_api_schedule(
user2.public_primary_key: 30, # daily 2h * 15d
}
assert_expected_shifts_export_response(response, (user1, user2), expected_on_call_times)
@pytest.mark.django_db
def test_oncall_shifts_export_truncate_events(
make_organization_and_user_with_token,
make_user,
make_schedule,
make_on_call_shift,
):
organization, _, token = make_organization_and_user_with_token()
user1 = make_user(organization=organization)
user1_public_primary_key = user1.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
# 24h shifts starting 9am on Mo, We and Fr
start_date = timezone.datetime(2023, 1, 1, 9, 0, 0, tzinfo=pytz.UTC)
make_on_call_shift(
organization=organization,
schedule=schedule,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
priority_level=1,
interval=1,
by_day=["MO", "WE", "FR"],
start=start_date,
rolling_users=[{user1.pk: user1_public_primary_key}],
rotation_start=start_date,
duration=timezone.timedelta(hours=24),
)
client = APIClient()
# request shifts on a Tu (ie. 00:00 - 09:00)
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-01-03&end_date=2023-01-03", format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {user1_public_primary_key: 9}
assert_expected_shifts_export_response(response, (user1,), expected_on_call_times)

View file

@ -145,7 +145,7 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
datetime_start = datetime.datetime.combine(start_date, datetime.time.min, tzinfo=pytz.UTC)
datetime_end = datetime_start + datetime.timedelta(
days=days_between_start_and_end - 1, hours=23, minutes=59, seconds=59
days=days_between_start_and_end, hours=23, minutes=59, seconds=59
)
final_schedule_events: ScheduleEvents = schedule.final_events(datetime_start, datetime_end)
@ -158,8 +158,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
"user_pk": user["pk"],
"user_email": user["email"],
"user_username": user["display_name"],
"shift_start": event["start"],
"shift_end": event["end"],
# truncate shift start/end exceeding the requested period
"shift_start": event["start"] if event["start"] >= datetime_start else datetime_start,
"shift_end": event["end"] if event["end"] <= datetime_end else datetime_end,
}
for event in final_schedule_events
for user in event["users"]

View file

@ -5,7 +5,6 @@ import typing
import humanize
from django.utils import timezone
from apps.slack.constants import DIVIDER
from apps.slack.models import SlackMessage
from apps.slack.scenarios import scenario_step
from apps.slack.types import Block, BlockActionType, EventPayload, PayloadType, ScenarioRoute
@ -21,17 +20,26 @@ logger.setLevel(logging.DEBUG)
SHIFT_SWAP_PK_ACTION_KEY = "shift_swap_request_pk"
def _schedule_slack_url(shift_swap_request) -> str:
schedule = shift_swap_request.schedule
return f"<{schedule.web_detail_page_link}|{schedule.name}>"
class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
def _generate_blocks(self, shift_swap_request: "ShiftSwapRequest") -> Block.AnyBlocks:
pk = shift_swap_request.pk
main_message_text = f"Your teammate {shift_swap_request.beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request."
main_message_text = (
f"*New shift swap request for {_schedule_slack_url(shift_swap_request)}*\n"
f"Your teammate {shift_swap_request.beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request."
)
datetime_format = SlackDateFormat.DATE_LONG_PRETTY
time_format = SlackDateFormat.TIME
shift_details = ""
for shift in shift_swap_request.shifts():
shifts = shift_swap_request.shifts()
for shift in shifts:
shift_start = shift["start"]
shift_start_posix = shift_start.timestamp()
shift_end = shift["end"]
@ -58,18 +66,22 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
},
},
),
typing.cast(
Block.Section,
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*📅 Shift Details*:\n\n{shift_details}",
},
},
),
]
if shifts:
blocks.append(
typing.cast(
Block.Section,
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Shift detail{'s' if len(shifts) > 1 else ''}*\n{shift_details}",
},
},
),
)
if description := shift_swap_request.description:
blocks.append(
typing.cast(
@ -78,7 +90,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*📝 Description*: {description}",
"text": f"*Description*\n{description}",
},
},
)
@ -92,7 +104,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Update*: this shift swap request has been deleted.",
"text": "❌ this shift swap request has been deleted",
},
},
),
@ -105,7 +117,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Update*: {shift_swap_request.benefactor.get_username_with_slack_verbal()} has taken the shift swap.",
"text": f" {shift_swap_request.benefactor.get_username_with_slack_verbal()} has accepted the shift swap request",
},
},
),
@ -124,10 +136,9 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
"elements": [
{
"type": "button",
"style": "primary",
"text": {
"type": "plain_text",
"text": "✔️ Accept Shift Swap Request",
"text": "Accept",
"emoji": True,
},
"value": json.dumps(value),
@ -138,24 +149,6 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
)
)
blocks.extend(
[
DIVIDER,
typing.cast(
Block.Context,
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": f"👀 View the shift swap within Grafana OnCall by clicking <{shift_swap_request.web_link}|here>.",
},
],
},
),
]
)
return blocks
def create_message(self, shift_swap_request: "ShiftSwapRequest") -> SlackMessage:
@ -226,8 +219,9 @@ class ShiftSwapRequestFollowUp(scenario_step.ScenarioStep):
"text": {
"type": "mrkdwn",
"text": (
f":exclamation: This shift swap request is still open and will start in {delta}.\n"
"Jump back into the thread and accept it if you're available!"
f"⚠️ This shift swap request for {_schedule_slack_url(shift_swap_request)} is "
f"still open and will start in {delta}. Jump back into the thread and accept it if "
"you're available!"
),
},
},

View file

@ -1,6 +1,8 @@
import logging
import typing
from django.core.exceptions import ObjectDoesNotExist
from apps.slack.scenarios import scenario_step
from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute
@ -71,7 +73,11 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
except SlackMessage.DoesNotExist:
return
alert_group = slack_message.get_alert_group()
try:
alert_group = slack_message.get_alert_group()
except ObjectDoesNotExist:
# SlackMessage instances without alert_group set (e.g., SSR Slack messages)
return
result = self._slack_client.api_call(
"chat.getPermalink",
@ -82,48 +88,33 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
if result["permalink"] is not None:
permalink = result["permalink"]
try:
slack_thread_message = ResolutionNoteSlackMessage.objects.get(
ts=message_ts,
thread_ts=thread_ts,
alert_group=alert_group,
if len(text) > 2900:
self._slack_client.api_call(
"chat.postEphemeral",
channel=channel,
user=slack_user_identity.slack_id,
text=":warning: Unable to show the <{}|message> in Resolution Note: the message is too long ({}). "
"Max length - 2900 symbols.".format(permalink, len(text)),
)
if len(text) > 2900:
if slack_thread_message.added_to_resolution_note:
self._slack_client.api_call(
"chat.postEphemeral",
channel=channel,
user=slack_user_identity.slack_id,
text=":warning: Unable to update the <{}|message> in Resolution Note: the message is too long ({}). "
"Max length - 2900 symbols.".format(permalink, len(text)),
)
return
return
slack_thread_message, created = ResolutionNoteSlackMessage.objects.get_or_create(
ts=message_ts,
thread_ts=thread_ts,
alert_group=alert_group,
defaults={
"user": self.user,
"added_by_user": self.user,
"text": text,
"slack_channel_id": channel,
"permalink": permalink,
},
)
if not created:
slack_thread_message.text = text
slack_thread_message.save()
except ResolutionNoteSlackMessage.DoesNotExist:
if len(text) > 2900:
self._slack_client.api_call(
"chat.postEphemeral",
channel=channel,
user=slack_user_identity.slack_id,
text=":warning: The <{}|message> will not be displayed in Resolution Note: "
"the message is too long ({}). Max length - 2900 symbols.".format(permalink, len(text)),
)
return
slack_thread_message = ResolutionNoteSlackMessage(
alert_group=alert_group,
user=self.user,
added_by_user=self.user,
text=text,
slack_channel_id=channel,
thread_ts=thread_ts,
ts=message_ts,
permalink=permalink,
)
slack_thread_message.save()
def delete_thread_message_from_resolution_note(
self, slack_user_identity: "SlackUserIdentity", payload: EventPayload
) -> None:

View file

@ -40,26 +40,16 @@ class TestBaseShiftSwapRequestStep:
step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization)
blocks = step._generate_blocks(ssr)
assert (
blocks[0]["text"]["text"]
== f"Your teammate {beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request."
assert blocks[0]["text"]["text"] == (
f"*New shift swap request for <{ssr.schedule.web_detail_page_link}|{ssr.schedule.name}>*\n"
f"Your teammate {beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request."
)
accept_button = blocks[2]
accept_button = blocks[1]
assert accept_button["elements"][0]["text"]["text"] == "✔️ Accept Shift Swap Request"
assert accept_button["elements"][0]["text"]["text"] == "Accept"
assert accept_button["type"] == "actions"
assert blocks[3]["type"] == "divider"
context_section = blocks[4]
assert context_section["type"] == "context"
assert (
context_section["elements"][0]["text"]
== f"👀 View the shift swap within Grafana OnCall by clicking <{ssr.web_link}|here>."
)
@patch("apps.schedules.models.ShiftSwapRequest.shifts")
@pytest.mark.parametrize(
"shifts,expected_text",
@ -108,7 +98,7 @@ class TestBaseShiftSwapRequestStep:
step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization)
blocks = step._generate_blocks(ssr)
assert blocks[1]["text"]["text"] == f"*📅 Shift Details*:\n\n{expected_text}"
assert blocks[1]["text"]["text"] == f"*Shift details*\n{expected_text}"
@pytest.mark.django_db
def test_generate_blocks_ssr_has_description(self, setup) -> None:
@ -118,7 +108,7 @@ class TestBaseShiftSwapRequestStep:
step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization)
blocks = step._generate_blocks(ssr)
assert blocks[2]["text"]["text"] == f"*📝 Description*: {description}"
assert blocks[1]["text"]["text"] == f"*Description*\n{description}"
@pytest.mark.django_db
def test_generate_blocks_ssr_is_deleted(self, setup) -> None:
@ -128,7 +118,7 @@ class TestBaseShiftSwapRequestStep:
step = scenarios.BaseShiftSwapRequestStep(ssr.organization.slack_team_identity, ssr.organization)
blocks = step._generate_blocks(ssr)
assert blocks[2]["text"]["text"] == "*Update*: this shift swap request has been deleted."
assert blocks[1]["text"]["text"] == "❌ this shift swap request has been deleted"
@pytest.mark.django_db
def test_generate_blocks_ssr_is_taken(self, setup) -> None:
@ -140,8 +130,8 @@ class TestBaseShiftSwapRequestStep:
blocks = step._generate_blocks(ssr)
assert (
blocks[2]["text"]["text"]
== f"*Update*: {benefactor.get_username_with_slack_verbal()} has taken the shift swap."
blocks[1]["text"]["text"]
== f" {benefactor.get_username_with_slack_verbal()} has accepted the shift swap request"
)
@patch("apps.slack.scenarios.shift_swap_requests.BaseShiftSwapRequestStep._generate_blocks")

View file

@ -0,0 +1,472 @@
from unittest.mock import Mock, call, patch
import pytest
from apps.alerts.models import ResolutionNoteSlackMessage
from apps.slack.scenarios.slack_channel_integration import SlackChannelMessageEventStep
@pytest.mark.django_db
class TestSlackChannelMessageEventStep:
@patch.object(SlackChannelMessageEventStep, "save_thread_message_for_resolution_note")
@patch.object(SlackChannelMessageEventStep, "delete_thread_message_from_resolution_note")
@pytest.mark.parametrize(
"payload,save_called,delete_called",
[
(
{
# does not have thread_ts attribute or subtype
"event": {},
},
False,
False,
),
(
{
# has thread_ts attribute but has subtype attribute that is not MESSAGE_CHANGED
"event": {
"thread_ts": "foo",
"subtype": "bar",
},
},
False,
False,
),
(
{
# has thread_ts attribute but not subtype attribute
"event": {
"thread_ts": "foo",
},
},
True,
False,
),
# MESSAGE_CHANGED event.subtype scenarios
(
{
"event": {
"subtype": "message_changed",
"message": {
"subtype": "bar",
"thread_ts": "hello",
},
},
},
False,
False,
),
(
{
"event": {
"subtype": "message_changed",
"message": {
"subtype": "bar",
},
},
},
False,
False,
),
(
{
"event": {
"subtype": "potato",
"message": {
"thread_ts": "bar",
},
},
},
False,
False,
),
(
{
"event": {
"subtype": "message_changed",
"message": {
"thread_ts": "bar",
},
},
},
True,
False,
),
(
{
"event": {
"subtype": "message_deleted",
"previous_message": {},
},
},
False,
False,
),
(
{
"event": {
"subtype": "message_deleted",
"previous_message": {},
},
},
False,
False,
),
(
{
"event": {
"subtype": "potato",
"previous_message": {
"thread_ts": "bar",
},
},
},
False,
False,
),
(
{
"event": {
"subtype": "message_deleted",
"previous_message": {
"thread_ts": "bar",
},
},
},
False,
True,
),
],
)
def test_process_scenario(
self,
mock_delete_thread_message_from_resolution_note,
mock_save_thread_message_for_resolution_note,
make_organization_and_user_with_slack_identities,
payload,
save_called,
delete_called,
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
if save_called:
mock_save_thread_message_for_resolution_note.assert_called_once_with(slack_user_identity, payload)
else:
mock_save_thread_message_for_resolution_note.assert_not_called()
if delete_called:
mock_delete_thread_message_from_resolution_note.assert_called_once_with(slack_user_identity, payload)
else:
mock_delete_thread_message_from_resolution_note.assert_not_called()
@patch("apps.alerts.models.ResolutionNoteSlackMessage")
def test_save_thread_message_for_resolution_note_no_slack_user_identity(
self, MockResolutionNoteSlackMessage, make_organization_and_user_with_slack_identities
) -> None:
organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities()
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step._slack_client = Mock()
step.save_thread_message_for_resolution_note(None, {})
step._slack_client.api_call.assert_not_called()
MockResolutionNoteSlackMessage.objects.get_or_create.assert_not_called()
@patch("apps.alerts.models.ResolutionNoteSlackMessage")
def test_save_thread_message_for_resolution_note_no_slack_message(
self, MockResolutionNoteSlackMessage, make_organization_and_user_with_slack_identities
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step._slack_client = Mock()
payload = {
"event": {
"channel": "potato",
"ts": 88945.4849,
"thread_ts": 16789.123,
"text": "hello",
},
}
step.save_thread_message_for_resolution_note(slack_user_identity, payload)
step._slack_client.api_call.assert_not_called()
MockResolutionNoteSlackMessage.objects.get_or_create.assert_not_called()
@patch("apps.alerts.models.ResolutionNoteSlackMessage")
def test_save_thread_message_for_resolution_note_really_long_text(
self,
MockResolutionNoteSlackMessage,
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_slack_message,
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
integration = make_alert_receive_channel(organization)
alert_group = make_alert_group(integration)
channel = "potato"
ts = 88945.4849
thread_ts = 16789.123
make_slack_message(alert_group, slack_id=thread_ts, channel_id=channel)
mock_permalink = "http://example.com"
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step._slack_client = Mock()
step._slack_client.api_call.side_effect = [{"permalink": mock_permalink}, None]
payload = {
"event": {
"channel": channel,
"ts": ts,
"thread_ts": thread_ts,
"text": "h" * 2901,
},
}
step.save_thread_message_for_resolution_note(slack_user_identity, payload)
step._slack_client.api_call.assert_has_calls(
[
call(
"chat.getPermalink",
channel=payload["event"]["channel"],
message_ts=payload["event"]["ts"],
),
call(
"chat.postEphemeral",
channel=payload["event"]["channel"],
user=slack_user_identity.slack_id,
text=":warning: Unable to show the <{}|message> in Resolution Note: the message is too long ({}). "
"Max length - 2900 symbols.".format(mock_permalink, len(payload["event"]["text"])),
),
]
)
MockResolutionNoteSlackMessage.objects.get_or_create.assert_not_called()
@pytest.mark.parametrize("resolution_note_slack_message_already_exists", [True, False])
def test_save_thread_message_for_resolution_note(
self,
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_slack_message,
make_resolution_note_slack_message,
resolution_note_slack_message_already_exists,
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
integration = make_alert_receive_channel(organization)
alert_group = make_alert_group(integration)
original_text = "original text"
new_text = "new text"
channel = "potato"
ts = 88945.4849
thread_ts = 16789.123
make_slack_message(alert_group, slack_id=thread_ts, channel_id=channel)
resolution_note_slack_message = None
if resolution_note_slack_message_already_exists:
resolution_note_slack_message = make_resolution_note_slack_message(
alert_group, user, user, ts=ts, thread_ts=thread_ts, text=original_text
)
mock_permalink = "http://example.com"
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step._slack_client = Mock()
step._slack_client.api_call.side_effect = [{"permalink": mock_permalink}, None]
payload = {
"event": {
"channel": channel,
"ts": ts,
"thread_ts": thread_ts,
"text": new_text,
},
}
step.save_thread_message_for_resolution_note(slack_user_identity, payload)
step._slack_client.api_call.assert_has_calls(
[
call(
"chat.getPermalink",
channel=payload["event"]["channel"],
message_ts=payload["event"]["ts"],
),
]
)
if resolution_note_slack_message_already_exists:
resolution_note_slack_message.refresh_from_db()
resolution_note_slack_message.text = new_text
else:
assert (
ResolutionNoteSlackMessage.objects.filter(
ts=ts,
thread_ts=thread_ts,
alert_group=alert_group,
).count()
== 1
)
@patch("apps.alerts.models.ResolutionNoteSlackMessage")
def test_delete_thread_message_from_resolution_note_no_slack_user_identity(
self, MockResolutionNoteSlackMessage, make_organization_and_user_with_slack_identities
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step.delete_thread_message_from_resolution_note(None, {})
MockResolutionNoteSlackMessage.objects.get.assert_not_called()
def test_delete_thread_message_from_resolution_note_no_message_found(
self, make_organization_and_user_with_slack_identities
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
channel = "potato"
ts = 88945.4849
thread_ts = 16789.123
payload = {
"event": {
"channel": channel,
"previous_message": {
"ts": ts,
"thread_ts": thread_ts,
},
},
}
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step.alert_group_slack_service = Mock()
step.delete_thread_message_from_resolution_note(slack_user_identity, payload)
step.alert_group_slack_service.assert_not_called()
def test_delete_thread_message_from_resolution_note(
self,
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_resolution_note_slack_message,
) -> None:
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
integration = make_alert_receive_channel(organization)
alert_group = make_alert_group(integration)
channel = "potato"
ts = 88945.4849
thread_ts = 16789.123
payload = {
"event": {
"channel": channel,
"previous_message": {
"ts": ts,
"thread_ts": thread_ts,
},
},
}
make_resolution_note_slack_message(
alert_group, user, user, ts=ts, thread_ts=thread_ts, slack_channel_id=channel
)
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step.alert_group_slack_service = Mock()
step.delete_thread_message_from_resolution_note(slack_user_identity, payload)
step.alert_group_slack_service.update_alert_group_slack_message.assert_called_once_with(alert_group)
assert (
ResolutionNoteSlackMessage.objects.filter(
ts=ts,
thread_ts=thread_ts,
slack_channel_id=channel,
).count()
== 0
)
def test_slack_message_has_no_alert_group(
self,
make_organization_and_user_with_slack_identities,
make_slack_message,
) -> None:
"""Thread messages for SlackMessage instances without alert_group set (e.g., SSR Slack messages)"""
(
organization,
user,
slack_team_identity,
slack_user_identity,
) = make_organization_and_user_with_slack_identities()
channel = "potato"
ts = 88945.4849
thread_ts = 16789.123
payload = {
"event": {
"channel": channel,
"ts": ts,
"thread_ts": thread_ts,
"text": "hello",
},
}
make_slack_message(alert_group=None, organization=organization, slack_id=thread_ts, channel_id=channel)
step = SlackChannelMessageEventStep(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert not ResolutionNoteSlackMessage.objects.exists()

View file

@ -53,26 +53,6 @@ def delete_oncall_connector_async(oncall_org_id):
raise e
# deprecated
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=None,
)
def create_slack_connector_async(slack_id, backend):
pass
# deprecated
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=None,
)
def delete_slack_connector_async(slack_id):
pass
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,

View file

@ -0,0 +1,31 @@
from celery import current_app
from settings.celery_task_routes import CELERY_TASK_ROUTES
"""
If a task has a legitimate reason to not have a queue assignment it can
be added here (In development, in process of deprecation, etc.) if possible
we should avoid @shared_dedicated_queue_retry_task or @shared_task and
remove entirely if it is not needed.
"""
COMMON_IGNORED_TASKS = set()
def check_celery_task_route_mapping(task_ids, ignored_prefixes, additional_ignored_tasks=None):
tasks = set(k for k in current_app.tasks.keys() if not k.startswith(ignored_prefixes))
tasks -= set(COMMON_IGNORED_TASKS)
if additional_ignored_tasks:
tasks -= set(additional_ignored_tasks)
tasks -= set(task_ids)
if tasks:
print(f"Unassigned queue for celery task {tasks}")
assert len(tasks) == 0
def test_celery_task_route_mapping():
"""
If this test does not pass make sure you have added any newly added
@shared_dedicated_queue_retry_task or @shared_task to CELERY_TASK_ROUTES
in engine/settings/celery_task_routes.py
"""
check_celery_task_route_mapping(CELERY_TASK_ROUTES.keys(), ("extensions", "celery"))

View file

@ -0,0 +1,162 @@
CELERY_TASK_ROUTES = {
# DEFAULT
"apps.alerts.tasks.sync_grafana_alerting_contact_points.disconnect_integration_from_alerting_contact_points": {
"queue": "default"
},
"apps.alerts.tasks.delete_alert_group.delete_alert_group": {"queue": "default"},
"apps.alerts.tasks.invalidate_web_cache_for_alert_group.invalidate_web_cache_for_alert_group": {"queue": "default"},
"apps.alerts.tasks.send_alert_group_signal.send_alert_group_signal": {"queue": "default"},
"apps.alerts.tasks.wipe.wipe": {"queue": "default"},
"common.oncall_gateway.tasks.create_oncall_connector_async": {"queue": "default"},
"common.oncall_gateway.tasks.delete_oncall_connector_async": {"queue": "default"},
"common.oncall_gateway.tasks.create_slack_connector_async_v2": {"queue": "default"},
"common.oncall_gateway.tasks.delete_slack_connector_async_v2": {"queue": "default"},
"apps.heartbeat.tasks.integration_heartbeat_checkup": {"queue": "default"},
"apps.heartbeat.tasks.process_heartbeat_task": {"queue": "default"},
"apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"},
"apps.metrics_exporter.tasks.start_recalculation_for_new_metric": {"queue": "default"},
"apps.metrics_exporter.tasks.save_organizations_ids_in_cache": {"queue": "default"},
"apps.mobile_app.tasks.new_shift_swap_request.notify_shift_swap_requests": {"queue": "default"},
"apps.mobile_app.tasks.new_shift_swap_request.notify_shift_swap_request": {"queue": "default"},
"apps.mobile_app.tasks.new_shift_swap_request.notify_user_about_shift_swap_request": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.refresh_ical_file": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.start_refresh_ical_files": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.refresh_ical_final_schedule": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_notify_about_gaps_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.check_gaps_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.notify_about_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.schedule_notify_about_gaps_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_check_gaps_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_check_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.schedule_notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.shift_swaps.slack_messages.create_shift_swap_request_message": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_messages.update_shift_swap_request_message": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_followups.send_shift_swap_request_slack_followups": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_followups.send_shift_swap_request_slack_followup": {"queue": "default"},
"apps.migration_tool.tasks.start_migration_from_old_amixr": {"queue": "default"},
"apps.migration_tool.tasks.migrate_schedules": {"queue": "default"},
"apps.migration_tool.tasks.migrate_integrations": {"queue": "default"},
"apps.migration_tool.tasks.migrate_routes": {"queue": "default"},
"apps.migration_tool.tasks.migrate_escalation_policies": {"queue": "default"},
"apps.migration_tool.tasks.start_migration_alert_groups": {"queue": "default"},
"apps.migration_tool.tasks.migrate_alert_group": {"queue": "default"},
"apps.migration_tool.tasks.start_migration_alerts": {"queue": "default"},
"apps.migration_tool.tasks.migrate_alert": {"queue": "default"},
"apps.migration_tool.tasks.start_migration_logs": {"queue": "default"},
"apps.migration_tool.tasks.migrate_log": {"queue": "default"},
"apps.migration_tool.tasks.start_migration_user_data": {"queue": "default"},
"apps.migration_tool.tasks.migrate_user_data": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.notify_about_gaps_in_schedule": {"queue": "default"},
"celery.backend_cleanup": {"queue": "default"},
"apps.heartbeat.tasks.check_heartbeats": {"queue": "default"},
"apps.oss_installation.tasks.send_cloud_heartbeat_task": {"queue": "default"},
"apps.oss_installation.tasks.send_usage_stats_report": {"queue": "default"},
"apps.oss_installation.tasks.sync_users_with_cloud": {"queue": "default"},
# CRITICAL
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},
"apps.alerts.tasks.distribute_alert.distribute_alert": {"queue": "critical"},
"apps.alerts.tasks.distribute_alert.send_alert_create_signal": {"queue": "critical"},
"apps.alerts.tasks.escalate_alert_group.escalate_alert_group": {"queue": "critical"},
"apps.alerts.tasks.invite_user_to_join_incident.invite_user_to_join_incident": {"queue": "critical"},
"apps.alerts.tasks.maintenance.check_maintenance_finished": {"queue": "critical"},
"apps.alerts.tasks.maintenance.disable_maintenance": {"queue": "critical"},
"apps.alerts.tasks.notify_all.notify_all_task": {"queue": "critical"},
"apps.alerts.tasks.notify_group.notify_group_task": {"queue": "critical"},
"apps.alerts.tasks.notify_ical_schedule_shift.notify_ical_schedule_shift": {"queue": "critical"},
"apps.alerts.tasks.notify_user.notify_user_task": {"queue": "critical"},
"apps.alerts.tasks.notify_user.perform_notification": {"queue": "critical"},
"apps.alerts.tasks.notify_user.send_user_notification_signal": {"queue": "critical"},
"apps.alerts.tasks.resolve_alert_group_by_source_if_needed.resolve_alert_group_by_source_if_needed": {
"queue": "critical"
},
"apps.alerts.tasks.resolve_by_last_step.resolve_by_last_step_task": {"queue": "critical"},
"apps.alerts.tasks.send_update_log_report_signal.send_update_log_report_signal": {"queue": "critical"},
"apps.alerts.tasks.send_update_resolution_note_signal.send_update_resolution_note_signal": {"queue": "critical"},
"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"},
"apps.mobile_app.tasks.new_alert_group.notify_user_about_new_alert_group": {"queue": "critical"},
"apps.mobile_app.tasks.going_oncall_notification.conditionally_send_going_oncall_push_notifications_for_schedule": {
"queue": "critical"
},
"apps.mobile_app.tasks.going_oncall_notification.conditionally_send_going_oncall_push_notifications_for_all_schedules": {
"queue": "critical"
},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_for_custom_events_for_organization": {"queue": "critical"},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_task": {"queue": "critical"},
# GRAFANA
"apps.grafana_plugin.tasks.sync.plugin_sync_organization_async": {"queue": "grafana"},
# LONG
"apps.alerts.tasks.alert_group_web_title_cache.update_web_title_cache_for_alert_receive_channel": {"queue": "long"},
"apps.alerts.tasks.alert_group_web_title_cache.update_web_title_cache": {"queue": "long"},
"apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.cleanup_organization_async": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.start_cleanup_deleted_organizations": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.start_sync_organizations": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.sync_organization_async": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.sync_team_members_for_organization_async": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.start_sync_regions": {"queue": "long"},
"apps.metrics_exporter.tasks.calculate_and_cache_metrics": {"queue": "long"},
"apps.metrics_exporter.tasks.calculate_and_cache_user_was_notified_metric": {"queue": "long"},
# SLACK
"apps.integrations.tasks.notify_about_integration_ratelimit_in_slack": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_alert_group_action_triggered_async": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_alert_group_update_log_report_async": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_create_alert_slack_representative_async": {"queue": "slack"},
"apps.slack.tasks.clean_slack_channel_leftovers": {"queue": "slack"},
"apps.slack.tasks.check_slack_message_exists_before_post_message_to_thread": {"queue": "slack"},
"apps.slack.tasks.clean_slack_integration_leftovers": {"queue": "slack"},
"apps.slack.tasks.populate_slack_channels": {"queue": "slack"},
"apps.slack.tasks.populate_slack_channels_for_team": {"queue": "slack"},
"apps.slack.tasks.populate_slack_user_identities": {"queue": "slack"},
"apps.slack.tasks.populate_slack_usergroups": {"queue": "slack"},
"apps.slack.tasks.populate_slack_usergroups_for_team": {"queue": "slack"},
"apps.slack.tasks.post_or_update_log_report_message_task": {"queue": "slack"},
"apps.slack.tasks.post_slack_rate_limit_message": {"queue": "slack"},
"apps.slack.tasks.send_message_to_thread_if_bot_not_in_channel": {"queue": "slack"},
"apps.slack.tasks.start_update_slack_user_group_for_schedules": {"queue": "slack"},
"apps.slack.tasks.unpopulate_slack_user_identities": {"queue": "slack"},
"apps.slack.tasks.update_incident_slack_message": {"queue": "slack"},
"apps.slack.tasks.update_slack_user_group_for_schedules": {"queue": "slack"},
"apps.slack.representatives.alert_group_representative.on_create_alert_slack_representative_async": {
"queue": "slack"
},
"apps.slack.representatives.alert_group_representative.on_alert_group_action_triggered_async": {"queue": "slack"},
"apps.slack.representatives.alert_group_representative.on_alert_group_update_log_report_async": {"queue": "slack"},
# TELEGRAM
"apps.telegram.tasks.edit_message": {"queue": "telegram"},
"apps.telegram.tasks.on_create_alert_telegram_representative_async": {"queue": "telegram"},
"apps.telegram.tasks.register_telegram_webhook": {"queue": "telegram"},
"apps.telegram.tasks.send_link_to_channel_message_or_fallback_to_full_alert_group": {"queue": "telegram"},
"apps.telegram.tasks.send_log_and_actions_message": {"queue": "telegram"},
# WEBHOOK
"apps.alerts.tasks.custom_button_result.custom_button_result": {"queue": "webhook"},
"apps.alerts.tasks.custom_webhook_result.custom_webhook_result": {"queue": "webhook"},
"apps.mobile_app.fcm_relay.fcm_relay_async": {"queue": "webhook"},
"apps.webhooks.tasks.trigger_webhook.execute_webhook": {"queue": "webhook"},
"apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"},
}

View file

@ -1,5 +1,6 @@
import os
from . import celery_task_routes
from .base import * # noqa: F401, F403
try:
@ -44,118 +45,7 @@ SECURE_REDIRECT_EXEMPT = [
]
SECURE_HSTS_SECONDS = 360000
CELERY_TASK_ROUTES = {
# DEFAULT
"apps.alerts.tasks.create_contact_points_for_datasource.create_contact_points_for_datasource": {"queue": "default"},
"apps.alerts.tasks.sync_grafana_alerting_contact_points.sync_grafana_alerting_contact_points": {"queue": "default"},
"apps.alerts.tasks.sync_grafana_alerting_contact_points.disconnect_integration_from_alerting_contact_points": {
"queue": "default"
},
"apps.alerts.tasks.delete_alert_group.delete_alert_group": {"queue": "default"},
"apps.alerts.tasks.send_alert_group_signal.send_alert_group_signal": {"queue": "default"},
"apps.alerts.tasks.wipe.wipe": {"queue": "default"},
"apps.heartbeat.tasks.integration_heartbeat_checkup": {"queue": "default"},
"apps.heartbeat.tasks.process_heartbeat_task": {"queue": "default"},
"apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"},
"apps.metrics_exporter.tasks.start_recalculation_for_new_metric": {"queue": "default"},
"apps.metrics_exporter.tasks.save_organizations_ids_in_cache": {"queue": "default"},
"apps.mobile_app.tasks.notify_shift_swap_requests": {"queue": "default"},
"apps.mobile_app.tasks.notify_shift_swap_request": {"queue": "default"},
"apps.mobile_app.tasks.notify_user_about_shift_swap_request": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.refresh_ical_file": {"queue": "default"},
"apps.schedules.tasks.refresh_ical_files.start_refresh_ical_files": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.notify_about_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_check_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_notify_about_empty_shifts_in_schedule": {
"queue": "default"
},
"apps.schedules.tasks.shift_swaps.slack_messages.create_shift_swap_request_message": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_messages.update_shift_swap_request_message": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_followups.send_shift_swap_request_slack_followups": {"queue": "default"},
"apps.schedules.tasks.shift_swaps.slack_followups.send_shift_swap_request_slack_followup": {"queue": "default"},
# CRITICAL
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},
"apps.alerts.tasks.distribute_alert.distribute_alert": {"queue": "critical"},
"apps.alerts.tasks.distribute_alert.send_alert_create_signal": {"queue": "critical"},
"apps.alerts.tasks.escalate_alert_group.escalate_alert_group": {"queue": "critical"},
"apps.alerts.tasks.invite_user_to_join_incident.invite_user_to_join_incident": {"queue": "critical"},
"apps.alerts.tasks.maintenance.check_maintenance_finished": {"queue": "critical"},
"apps.alerts.tasks.maintenance.disable_maintenance": {"queue": "critical"},
"apps.alerts.tasks.notify_all.notify_all_task": {"queue": "critical"},
"apps.alerts.tasks.notify_group.notify_group_task": {"queue": "critical"},
"apps.alerts.tasks.notify_ical_schedule_shift.notify_ical_schedule_shift": {"queue": "critical"},
"apps.alerts.tasks.notify_user.notify_user_task": {"queue": "critical"},
"apps.alerts.tasks.notify_user.perform_notification": {"queue": "critical"},
"apps.alerts.tasks.notify_user.send_user_notification_signal": {"queue": "critical"},
"apps.alerts.tasks.resolve_alert_group_by_source_if_needed.resolve_alert_group_by_source_if_needed": {
"queue": "critical"
},
"apps.alerts.tasks.resolve_by_last_step.resolve_by_last_step_task": {"queue": "critical"},
"apps.alerts.tasks.send_update_log_report_signal.send_update_log_report_signal": {"queue": "critical"},
"apps.alerts.tasks.send_update_resolution_note_signal.send_update_resolution_note_signal": {"queue": "critical"},
"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"},
# TODO: remove apps.mobile_app.tasks.notify_user_async in a future release
"apps.mobile_app.tasks.notify_user_async": {"queue": "critical"},
"apps.mobile_app.tasks.notify_user_about_new_alert_group": {"queue": "critical"},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_for_custom_events_for_organization": {"queue": "critical"},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_task": {"queue": "critical"},
# LONG
"apps.alerts.tasks.alert_group_web_title_cache.update_web_title_cache_for_alert_receive_channel": {"queue": "long"},
"apps.alerts.tasks.alert_group_web_title_cache.update_web_title_cache": {"queue": "long"},
"apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.cleanup_organization_async": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.start_cleanup_deleted_organizations": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.start_sync_organizations": {"queue": "long"},
"apps.grafana_plugin.tasks.sync.sync_organization_async": {"queue": "long"},
"apps.metrics_exporter.tasks.calculate_and_cache_metrics": {"queue": "long"},
"apps.metrics_exporter.tasks.calculate_and_cache_user_was_notified_metric": {"queue": "long"},
# SLACK
"apps.integrations.tasks.notify_about_integration_ratelimit_in_slack": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_alert_group_action_triggered_async": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_alert_group_update_log_report_async": {"queue": "slack"},
"apps.slack.helpers.alert_group_representative.on_create_alert_slack_representative_async": {"queue": "slack"},
"apps.slack.tasks.check_slack_message_exists_before_post_message_to_thread": {"queue": "slack"},
"apps.slack.tasks.clean_slack_integration_leftovers": {"queue": "slack"},
"apps.slack.tasks.populate_slack_channels": {"queue": "slack"},
"apps.slack.tasks.populate_slack_channels_for_team": {"queue": "slack"},
"apps.slack.tasks.populate_slack_user_identities": {"queue": "slack"},
"apps.slack.tasks.populate_slack_usergroups": {"queue": "slack"},
"apps.slack.tasks.populate_slack_usergroups_for_team": {"queue": "slack"},
"apps.slack.tasks.post_or_update_log_report_message_task": {"queue": "slack"},
"apps.slack.tasks.post_slack_rate_limit_message": {"queue": "slack"},
"apps.slack.tasks.send_message_to_thread_if_bot_not_in_channel": {"queue": "slack"},
"apps.slack.tasks.start_update_slack_user_group_for_schedules": {"queue": "slack"},
"apps.slack.tasks.unpopulate_slack_user_identities": {"queue": "slack"},
"apps.slack.tasks.update_incident_slack_message": {"queue": "slack"},
"apps.slack.tasks.update_slack_user_group_for_schedules": {"queue": "slack"},
# TELEGRAM
"apps.telegram.tasks.edit_message": {"queue": "telegram"},
"apps.telegram.tasks.on_create_alert_telegram_representative_async": {"queue": "telegram"},
"apps.telegram.tasks.register_telegram_webhook": {"queue": "telegram"},
"apps.telegram.tasks.send_link_to_channel_message_or_fallback_to_full_alert_group": {"queue": "telegram"},
"apps.telegram.tasks.send_log_and_actions_message": {"queue": "telegram"},
# WEBHOOK
"apps.alerts.tasks.custom_button_result.custom_button_result": {"queue": "webhook"},
"apps.mobile_app.fcm_relay.fcm_relay_async": {"queue": "webhook"},
}
CELERY_TASK_ROUTES = celery_task_routes.CELERY_TASK_ROUTES
REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": (

View file

@ -55,6 +55,7 @@ module.exports = {
* https://github.com/jsx-eslint/eslint-plugin-react/issues/3325
*/
'react/prop-types': 'off',
'react/no-unused-prop-types': 'off',
'react/jsx-key': 'warn',
'react/jsx-no-target-blank': 'warn',
'react/no-unescaped-entities': 'off',

View file

@ -4,12 +4,17 @@
overflow: hidden;
}
.avatarSize-xs {
width: 12px;
height: 12px;
}
.avatarSize-small {
width: 16px;
height: 16px;
}
.avatarSize-big {
.avatarSize-medium {
width: 24px;
height: 24px;
}

View file

@ -6,7 +6,7 @@ import styles from './Avatar.module.css';
interface AvatarProps {
src: string;
size: string;
size: 'xs' | 'small' | 'medium' | 'large';
className?: string;
}

View file

@ -39,6 +39,10 @@
text-decoration: underline;
}
&--xs {
font-size: 8px;
}
&--small {
font-size: 12px;
}

View file

@ -14,7 +14,7 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
type?: TextType;
strong?: boolean;
underline?: boolean;
size?: 'small' | 'medium' | 'large';
size?: 'xs' | 'small' | 'medium' | 'large';
keyboard?: boolean;
className?: string;
wrap?: boolean;

View file

@ -189,7 +189,7 @@ const UserResponder = ({ important, data, onImportantChange, handleDelete }) =>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
<Avatar size="big" src={data?.avatar} />
<Avatar size="medium" src={data?.avatar} />
</div>
<Text className={cx('responder-name')}>{data?.username}</Text>
{data.notification_chain_verbal.default || data.notification_chain_verbal.important ? (

View file

@ -1,54 +0,0 @@
import React, { useEffect } from 'react';
import { Alert, AlertVariant, Button, HorizontalGroup } from '@grafana/ui';
import { observer } from 'mobx-react';
import Text from 'components/Text/Text';
import { IRMPlanStatus } from 'models/alertgroup/alertgroup.types';
import { useStore } from 'state/useStore';
const IRMBanner: React.FC = observer(() => {
const store = useStore();
const {
alertGroupStore,
alertGroupStore: { irmPlan },
} = store;
useEffect(() => {
alertGroupStore.fetchIRMPlan();
}, []);
if (store.isOpenSource() || !irmPlan?.limits) {
return null;
}
if (irmPlan.limits.isIrmPro || irmPlan.limits.status === IRMPlanStatus.WithinLimits) {
return null;
}
const statusSeverity: { [key: string]: AlertVariant } = {
[IRMPlanStatus.WithinLimits]: 'success',
[IRMPlanStatus.NearLimit]: 'warning',
[IRMPlanStatus.AtLimit]: 'error',
};
return (
<Alert
title={
(
<HorizontalGroup justify={'space-between'}>
<Text type={'secondary'}>
<div dangerouslySetInnerHTML={{ __html: irmPlan.limits.reasonHTML }} />
</Text>
<Button variant={'secondary'} onClick={() => window.open(irmPlan.limits.upgradeURL, '_blank')}>
Upgrade
</Button>
</HorizontalGroup>
) as any
}
severity={statusSeverity[irmPlan.limits.status]}
buttonContent={undefined}
/>
);
});
export default IRMBanner;

View file

@ -12,14 +12,6 @@
width: 100%;
}
.root:first-child {
padding-top: 26px;
}
.root:last-child {
padding-bottom: 26px;
}
.root:hover {
background: var(--secondary-background);
}

View file

@ -28,6 +28,7 @@ const cx = cn.bind(styles);
interface ShiftSwapFormProps {
id: ShiftSwap['id'] | 'new';
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
params: Partial<ShiftSwap>;
currentTimezone: Timezone;
@ -37,12 +38,15 @@ interface ShiftSwapFormProps {
}
const ShiftSwapForm = (props: ShiftSwapFormProps) => {
const { onUpdate, onHide, id, scheduleId, params: defaultParams, currentTimezone } = props;
const { onUpdate, onHide, id, scheduleId, startMoment, params: defaultParams, currentTimezone } = props;
const [shiftSwap, setShiftSwap] = useState({ ...defaultParams });
const store = useStore();
const { scheduleStore } = store;
const {
scheduleStore,
userStore: { currentUserPk },
} = store;
useEffect(() => {
if (id !== 'new') {
@ -50,6 +54,12 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
}
}, [id]);
const handleHide = useCallback(() => {
scheduleStore.clearPreview();
onHide();
}, []);
useEffect(() => {
if (defaultParams) {
setShiftSwap({ ...shiftSwap, swap_start: defaultParams.swap_start, swap_end: defaultParams.swap_end });
@ -58,7 +68,9 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
const handleShiftSwapStartChange = useCallback(
(value) => {
setShiftSwap({ ...shiftSwap, swap_start: getUTCString(value) });
const diff = dayjs(shiftSwap.swap_end).diff(dayjs(shiftSwap.swap_start));
setShiftSwap({ ...shiftSwap, swap_start: getUTCString(value), swap_end: getUTCString(value.add(diff)) });
},
[shiftSwap]
);
@ -70,6 +82,16 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
[shiftSwap]
);
useEffect(() => {
if (id === 'new') {
store.scheduleStore.updateShiftsSwapPreview(scheduleId, startMoment, {
id: 'new',
beneficiary: currentUserPk,
...shiftSwap,
});
}
}, [shiftSwap, startMoment]);
const handleDescriptionChange = useCallback(
(event) => {
setShiftSwap({ ...shiftSwap, description: event.target.value });
@ -98,6 +120,12 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
onUpdate();
}, [id]);
useEffect(() => {
if (shiftSwap?.beneficiary && !store.userStore.items[shiftSwap.beneficiary]) {
store.userStore.updateItem(shiftSwap.beneficiary);
}
}, [shiftSwap?.beneficiary]);
const beneficiaryName = shiftSwap?.beneficiary && store.userStore.items[shiftSwap.beneficiary]?.name;
const isNew = id === 'new';
@ -108,7 +136,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
top="0"
isOpen
width="430px"
onDismiss={onHide}
onDismiss={handleHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: 200 }}>
<div {...props}>{children}</div>
@ -120,26 +148,30 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm">
{isNew && <Tag color={SHIFT_SWAP_COLOR}>New</Tag>}
<Text.Title level={5} editable>
Shift swap
</Text.Title>
<Text.Title level={5}>{isNew ? 'Shift swap request' : 'Shift swap'}</Text.Title>
</HorizontalGroup>
<HorizontalGroup>
{!isNew && (
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
<WithConfirm title="Are you sure to delete shift swap request?" confirmText="Delete">
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDelete} />
<IconButton
variant="secondary"
tooltip="Delete"
name="trash-alt"
onClick={handleDelete}
disabled={shiftSwap.beneficiary !== currentUserPk}
/>
</WithConfirm>
</WithPermissionControlTooltip>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
<IconButton name="times" variant="secondary" tooltip="Close" onClick={onHide} />
<IconButton name="times" variant="secondary" tooltip="Close" onClick={handleHide} />
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('fields')}>
{!isNew && (
<Field label="Creator">
<Field label="Requested by">
<Input disabled value={beneficiaryName}></Input>
</Field>
)}
@ -169,7 +201,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
</TextArea>
</Field>
{!isNew && (
<Field label="Taken by">
<Field label="Swapped by">
{shiftSwap?.benefactor ? (
<UserItem
pk={shiftSwap?.benefactor}
@ -178,7 +210,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
shiftEnd={shiftSwap.swap_end}
/>
) : (
<Text type="secondary">Not taken yet</Text>
<Text type="secondary">Not accepted yet</Text>
)}
</Field>
)}
@ -193,8 +225,12 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
Create
</Button>
) : (
<Button variant="primary" onClick={handleTake} disabled={Boolean(isPastDue || shiftSwap?.benefactor)}>
Take
<Button
variant="primary"
onClick={handleTake}
disabled={Boolean(isPastDue || shiftSwap?.benefactor || shiftSwap.beneficiary === currentUserPk)}
>
Accept
</Button>
)}
</WithPermissionControlTooltip>

View file

@ -24,11 +24,6 @@
margin: 16px 0;
}
.rotations-plus-title {
display: flex;
flex-direction: column;
}
.layer {
display: block;
}
@ -49,8 +44,15 @@
background: rgba(204, 204, 220, 0.12);
}
.rotations-plus-title {
display: flex;
flex-direction: column;
}
.header-plus-content {
position: relative;
padding-top: 26px;
padding-bottom: 26px;
}
.layer-header {

View file

@ -17,13 +17,12 @@ import { getColor, getLayersFromStore } from 'models/schedule/schedule.helpers';
import { Layer, Schedule, ScheduleType, Shift, ShiftSwap, Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { getUTCString } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findClosestUserEvent, findColor } from './Rotations.helpers';
import { findColor } from './Rotations.helpers';
import styles from './Rotations.module.css';
@ -75,9 +74,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
filters,
onShowShiftSwapForm,
onSlotClick,
store: {
userStore: { currentUserPk },
},
} = this.props;
const { layerPriority, shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state;
@ -115,24 +111,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</Text.Title>
</div>
<HorizontalGroup>
<Button
variant="secondary"
onClick={() => {
const closestEvent = findClosestUserEvent(dayjs(), currentUserPk, layers);
const swapStart = closestEvent
? dayjs(closestEvent.start)
: dayjs().tz(currentTimezone).startOf('day').add(1, 'day');
const swapEnd = closestEvent ? dayjs(closestEvent.end) : swapStart.add(1, 'day');
onShowShiftSwapForm('new', {
swap_start: getUTCString(swapStart),
swap_end: getUTCString(swapEnd),
});
}}
>
Request shift swap
</Button>
{disabled ? (
isTypeReadOnly ? (
<Tooltip content="Ical and API/Terraform rotations are read-only here" placement="top">
@ -176,7 +154,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
<Text type="secondary">Layer {layer.priority}</Text>
</HorizontalGroup>
</div>
<div className={cx('rotations')}>
<div className={cx('header-plus-content')}>
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
{!currentTimeHidden && (
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />

View file

@ -11,7 +11,7 @@ import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import {
flattenFinalShifs,
flattenShiftEvents,
getLayersFromStore,
getOverridesFromStore,
getShiftsFromStore,
@ -60,7 +60,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
const currentTimeX = diff / base;
const shifts = flattenFinalShifs(getShiftsFromStore(store, scheduleId, startMoment));
const shifts = flattenShiftEvents(getShiftsFromStore(store, scheduleId, startMoment));
const layers = getLayersFromStore(store, scheduleId, startMoment);

View file

@ -12,16 +12,22 @@ import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { getOverrideColor, getOverridesFromStore } from 'models/schedule/schedule.helpers';
import { Schedule, Shift, ShiftEvents } from 'models/schedule/schedule.types';
import {
getLayersFromStore,
getOverrideColor,
getOverridesFromStore,
getShiftSwapsFromStore,
SHIFT_SWAP_COLOR,
} from 'models/schedule/schedule.helpers';
import { Schedule, Shift, ShiftEvents, ShiftSwap } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { getStartOfDay } from 'pages/schedule/Schedule.helpers';
import { getStartOfDay, getUTCString } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findColor } from './Rotations.helpers';
import { findClosestUserEvent, findColor } from './Rotations.helpers';
import styles from './Rotations.module.css';
@ -35,6 +41,7 @@ interface ScheduleOverridesProps extends WithStoreProps {
scheduleId: Schedule['id'];
shiftIdToShowRotationForm?: Shift['id'] | 'new';
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial<ShiftSwap>) => void;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
@ -67,12 +74,20 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
disabled,
shiftStartToShowOverrideForm: propsShiftStartToShowOverrideForm,
shiftEndToShowOverrideForm: propsShiftEndToShowOverrideForm,
onShowShiftSwapForm,
filters,
store: {
userStore: { currentUserPk },
},
} = this.props;
const { shiftStartToShowOverrideForm, shiftEndToShowOverrideForm } = this.state;
const shifts = getOverridesFromStore(store, scheduleId, startMoment) as ShiftEvents[];
const layers = getLayersFromStore(store, scheduleId, startMoment);
const shiftSwaps = getShiftSwapsFromStore(store, scheduleId, startMoment);
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
@ -91,38 +106,81 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<HorizontalGroup justify="space-between">
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Overrides
Overrides and swaps
</Text.Title>
</div>
{isTypeReadOnly ? (
<Tooltip content="You can set an override using the override calendar" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
<HorizontalGroup>
<Button
variant="secondary"
disabled={disabled}
onClick={() => {
const closestEvent = findClosestUserEvent(dayjs(), currentUserPk, layers);
const swapStart = closestEvent
? dayjs(closestEvent.start)
: dayjs().tz(currentTimezone).startOf('day').add(1, 'day');
const swapEnd = closestEvent ? dayjs(closestEvent.end) : swapStart.add(1, 'day');
onShowShiftSwapForm('new', {
swap_start: getUTCString(swapStart),
swap_end: getUTCString(swapEnd),
});
}}
>
Request shift swap
</Button>
{isTypeReadOnly ? (
<Tooltip content="You can set an override using the override calendar" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
Add override
</Button>
</div>
</Tooltip>
) : (
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
<Button disabled={disabled} icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</div>
</Tooltip>
) : (
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
<Button disabled={disabled} icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</WithPermissionControlTooltip>
)}
</WithPermissionControlTooltip>
)}
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shiftSwaps && shiftSwaps.length
? shiftSwaps.map(({ isPreview, events }, index) => (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={events}
color={SHIFT_SWAP_COLOR}
startMoment={startMoment}
currentTimezone={currentTimezone}
onSlotClick={(event) => {
if (event.is_gap) {
return;
}
onShowShiftSwapForm(event.shiftSwapId);
}}
transparent={isPreview}
filters={filters}
/>
</CSSTransition>
))
: null}
</TransitionGroup>
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
<CSSTransition key={rotationIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
shifts.map(({ shiftId, isPreview, events }, index) => (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={rotationIndex}
scheduleId={scheduleId}
events={events}
color={getOverrideColor(rotationIndex)}
color={getOverrideColor(index)}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(shiftStart, shiftEnd) => {
@ -136,6 +194,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={0}
events={[]}
scheduleId={scheduleId}
startMoment={startMoment}

View file

@ -33,6 +33,21 @@
visibility: hidden;
}
.root__type_shift-swap {
border-radius: 10px;
background: #ff99002e;
height: 20px;
}
.no-user {
width: 12px;
height: 12px;
background: var(--tag-background-primary);
border-radius: 50%;
display: flex;
justify-content: center;
}
.root__inactive {
opacity: 0.3;
}

View file

@ -1,10 +1,11 @@
import React, { FC, useCallback, useMemo } from 'react';
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { Button, HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types';
import Text from 'components/Text/Text';
import WorkingHours from 'components/WorkingHours/WorkingHours';
@ -48,140 +49,303 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
filters,
onClick,
} = props;
const { users } = event;
const getShiftSwapClickHandler = useCallback((swapId: ShiftSwap['id']) => {
return (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
onShiftSwapClick(swapId);
};
}, []);
const start = dayjs(event.start);
const end = dayjs(event.end);
const duration = end.diff(start, 'seconds');
const store = useStore();
const base = 60 * 60 * 24 * 7;
const width = duration / base;
const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now;
const currentMoment = useMemo(() => dayjs(), []);
const enableWebOverrides = store.scheduleStore.items[scheduleId]?.enable_web_overrides;
const renderEvent = (event): React.ReactElement | React.ReactElement[] => {
if (event.shiftSwapId) {
return (
<ShiftSwapEvent
currentMoment={currentMoment}
event={event}
simplified={simplified}
currentTimezone={currentTimezone}
/>
);
}
return (
<div className={cx('stack')} style={{ width: `${width * 100}%` }} onClick={onClick}>
{event.is_gap ? (
if (event.is_gap) {
return (
<Tooltip content={<ScheduleGapDetails event={event} currentTimezone={currentTimezone} />}>
<div className={cx('root', 'root__type_gap')} />
</Tooltip>
) : event.is_empty ? (
);
}
if (event.is_empty) {
return (
<div
className={cx('root')}
style={{
backgroundColor: color,
}}
/>
) : (
users.map(({ display_name, pk: userPk, swap_request }) => {
const storeUser = store.userStore.items[userPk];
);
}
const isCurrentUserSlot = userPk === store.userStore.currentUserPk;
const inactive = filters && filters.users.length && !filters.users.includes(userPk);
return (
<RegularEvent
event={event}
scheduleId={scheduleId}
handleAddOverride={handleAddOverride}
handleAddShiftSwap={handleAddShiftSwap}
onShiftSwapClick={onShiftSwapClick}
filters={filters}
start={start}
duration={duration}
currentTimezone={currentTimezone}
simplified={simplified}
color={color}
currentMoment={currentMoment}
/>
);
};
const title = storeUser ? getTitle(storeUser) : display_name;
const isOncall = Boolean(
storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk)
);
const isShiftSwap = Boolean(swap_request);
let backgroundColor = color;
if (isShiftSwap) {
backgroundColor = SHIFT_SWAP_COLOR;
}
const scheduleSlotContent = (
<div
className={cx('root', { root__inactive: inactive })}
style={{
backgroundColor,
}}
onClick={swap_request ? getShiftSwapClickHandler(swap_request.pk) : undefined}
>
{storeUser && (!swap_request || swap_request.user) && (
<WorkingHours
className={cx('working-hours')}
timezone={storeUser.timezone}
workingHours={storeUser.working_hours}
startMoment={start}
duration={duration}
/>
)}
<div className={cx('title')}>
{swap_request && !swap_request.user ? <Icon name="user-arrows" /> : title}
</div>
</div>
);
if (!storeUser) {
return scheduleSlotContent;
} // show without a tooltip as we're lacking user info
return (
<Tooltip
interactive
key={userPk}
content={
<ScheduleSlotDetails
isShiftSwap={isShiftSwap}
beneficiaryName={
isShiftSwap ? (swap_request.user ? swap_request.user.display_name : display_name) : undefined
}
benefactorName={isShiftSwap ? (swap_request.user ? display_name : undefined) : undefined}
user={storeUser}
isOncall={isOncall}
currentTimezone={currentTimezone}
event={event}
handleAddOverride={
!enableWebOverrides || simplified || event.is_override || isShiftSwap
? undefined
: handleAddOverride
}
handleAddShiftSwap={simplified || isShiftSwap || !isCurrentUserSlot ? undefined : handleAddShiftSwap}
simplified={simplified}
color={backgroundColor}
/>
}
>
{scheduleSlotContent}
</Tooltip>
);
})
)}
return (
<div className={cx('stack')} style={{ width: `${width * 100}%` }} onClick={onClick}>
{renderEvent(event)}
</div>
);
});
export default ScheduleSlot;
interface ShiftSwapEventProps {
event: Event;
currentTimezone: Timezone;
simplified: boolean;
currentMoment: dayjs.Dayjs;
}
const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
const { event, currentTimezone, simplified, currentMoment } = props;
const store = useStore();
const shiftSwap = store.scheduleStore.shiftSwaps[event.shiftSwapId];
useEffect(() => {
if (shiftSwap?.beneficiary && !store.userStore.items[shiftSwap.beneficiary]) {
store.userStore.updateItem(shiftSwap.beneficiary);
}
}, [shiftSwap?.beneficiary]);
useEffect(() => {
if (shiftSwap?.benefactor && !store.userStore.items[shiftSwap.benefactor]) {
store.userStore.updateItem(shiftSwap.benefactor);
}
}, [shiftSwap?.benefactor]);
const beneficiary = store.userStore.items[shiftSwap?.beneficiary];
const benefactor = store.userStore.items[shiftSwap?.benefactor];
const scheduleSlotContent = (
<div className={cx('root', { 'root__type_shift-swap': true })}>
{shiftSwap && (
<HorizontalGroup spacing="xs">
{beneficiary && <Avatar size="xs" src={beneficiary.avatar} />}
{benefactor ? (
<Avatar size="xs" src={benefactor.avatar} />
) : (
<div className={cx('no-user')}>
<Text size="xs" type="primary">
?
</Text>
</div>
)}
</HorizontalGroup>
)}
</div>
);
if (!shiftSwap) {
return scheduleSlotContent;
}
return (
<Tooltip
interactive
content={
<ScheduleSlotDetails
isShiftSwap
beneficiaryName={beneficiary?.name}
user={benefactor || beneficiary}
benefactorName={benefactor?.name}
currentTimezone={currentTimezone}
event={event}
simplified={simplified}
color={SHIFT_SWAP_COLOR}
currentMoment={currentMoment}
/>
}
>
{scheduleSlotContent}
</Tooltip>
);
};
interface RegularEventProps {
event: Event;
scheduleId: Schedule['id'];
currentTimezone: Timezone;
handleAddOverride: (event: React.MouseEvent<HTMLDivElement>) => void;
handleAddShiftSwap: (event: React.MouseEvent<HTMLDivElement>) => void;
onShiftSwapClick: (id: ShiftSwap['id']) => void;
simplified: boolean;
color?: string;
filters?: ScheduleFiltersType;
start: dayjs.Dayjs;
duration: number;
currentMoment: dayjs.Dayjs;
}
const RegularEvent = (props: RegularEventProps) => {
const {
event,
scheduleId,
onShiftSwapClick,
filters,
color,
currentTimezone,
simplified,
start,
duration,
handleAddOverride,
handleAddShiftSwap,
currentMoment,
} = props;
const store = useStore();
const { users } = event;
const getShiftSwapClickHandler = useCallback(
(swapId: ShiftSwap['id']) => {
return (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
onShiftSwapClick(swapId);
};
},
[onShiftSwapClick]
);
const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now;
const enableWebOverrides = store.scheduleStore.items[scheduleId]?.enable_web_overrides;
return (
<>
{users.map(({ display_name, pk: userPk, swap_request }) => {
const storeUser = store.userStore.items[userPk];
const isCurrentUserSlot = userPk === store.userStore.currentUserPk;
const inactive = filters && filters.users.length && !filters.users.includes(userPk);
const title = storeUser ? getTitle(storeUser) : display_name;
const isOncall = Boolean(
storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk)
);
const isShiftSwap = Boolean(swap_request);
let backgroundColor = color;
if (isShiftSwap) {
backgroundColor = SHIFT_SWAP_COLOR;
}
const scheduleSlotContent = (
<div
className={cx('root', { root__inactive: inactive })}
style={{
backgroundColor,
}}
onClick={swap_request ? getShiftSwapClickHandler(swap_request.pk) : undefined}
>
{storeUser && (!swap_request || swap_request.user) && (
<WorkingHours
className={cx('working-hours')}
timezone={storeUser.timezone}
workingHours={storeUser.working_hours}
startMoment={start}
duration={duration}
/>
)}
<div className={cx('title')}>
{swap_request && !swap_request.user ? <Icon name="user-arrows" /> : title}
</div>
</div>
);
if (!storeUser) {
return scheduleSlotContent;
} // show without a tooltip as we're lacking user info
return (
<Tooltip
interactive
key={userPk}
content={
<ScheduleSlotDetails
isShiftSwap={isShiftSwap}
beneficiaryName={
isShiftSwap ? (swap_request.user ? swap_request.user.display_name : display_name) : undefined
}
benefactorName={isShiftSwap ? (swap_request.user ? display_name : undefined) : undefined}
user={storeUser}
isOncall={isOncall}
currentTimezone={currentTimezone}
event={event}
handleAddOverride={
!enableWebOverrides ||
simplified ||
event.is_override ||
isShiftSwap ||
currentMoment.isAfter(dayjs(event.end))
? undefined
: handleAddOverride
}
handleAddShiftSwap={
simplified || isShiftSwap || !isCurrentUserSlot || currentMoment.isAfter(dayjs(event.start))
? undefined
: handleAddShiftSwap
}
simplified={simplified}
color={backgroundColor}
currentMoment={currentMoment}
/>
}
>
{scheduleSlotContent}
</Tooltip>
);
})}
</>
);
};
interface ScheduleSlotDetailsProps {
user: User;
isOncall: boolean;
isOncall?: boolean;
currentTimezone: Timezone;
event: Event;
handleAddOverride: (event: React.SyntheticEvent) => void;
handleAddShiftSwap: (event: React.SyntheticEvent) => void;
handleAddOverride?: (event: React.SyntheticEvent) => void;
handleAddShiftSwap?: (event: React.SyntheticEvent) => void;
simplified?: boolean;
color: string;
isShiftSwap?: boolean;
beneficiaryName?: string;
benefactorName?: string;
currentMoment: dayjs.Dayjs;
}
const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
@ -195,13 +359,12 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
isShiftSwap,
beneficiaryName,
benefactorName,
currentMoment,
} = props;
const store = useStore();
const { scheduleStore } = store;
const currentMoment = useMemo(() => dayjs(), []);
const shift = scheduleStore.shifts[event.shift?.pk];
return (
@ -223,15 +386,15 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<VerticalGroup spacing="xs">
<Text type="primary">Swap pair</Text>
<Text type="primary" className={cx('username')}>
{beneficiaryName} <Text type="secondary">(creator)</Text>
{beneficiaryName} <Text type="secondary"> (requested by)</Text>
</Text>
{benefactorName ? (
<Text type="primary" className={cx('username')}>
{benefactorName} <Text type="secondary">(taken by)</Text>
{benefactorName} <Text type="secondary"> (accepted by)</Text>
</Text>
) : (
<Text type="secondary" className={cx('username')}>
Not taken yet
Not accepted yet
</Text>
)}
</VerticalGroup>
@ -248,8 +411,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<Text type="primary" className={cx('second-column')}>
User local time
<br />
{currentMoment.tz(user.timezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(user.timezone))})
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(user?.timezone))})
</Text>
<Text type="secondary">
Current timezone

View file

@ -1,4 +1,3 @@
import { IRMPlanStatus } from 'models/alertgroup/alertgroup.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { User } from 'models/user/user.types';
@ -31,7 +30,6 @@ export interface AlertReceiveChannel {
author: User['pk'];
team: GrafanaTeam['id'];
created_at: string;
status: IRMPlanStatus;
integration_url: string;
inbound_email: string;
allow_source_based_resolving: boolean;

View file

@ -11,7 +11,7 @@ import { SelectOption } from 'state/types';
import { openErrorNotification, refreshPageError, showApiError } from 'utils';
import LocationHelper from 'utils/LocationHelper';
import { Alert, AlertAction, IncidentStatus, ResponseIRMPlan } from './alertgroup.types';
import { Alert, AlertAction, IncidentStatus } from './alertgroup.types';
export class AlertGroupStore extends BaseStore {
@observable.shallow
@ -70,9 +70,6 @@ export class AlertGroupStore extends BaseStore {
@observable
liveUpdatesPaused = false;
@observable
irmPlan: ResponseIRMPlan = undefined;
constructor(rootStore: RootStore) {
super(rootStore);
@ -219,12 +216,6 @@ export class AlertGroupStore extends BaseStore {
});
}
async fetchIRMPlan() {
if (!this.rootStore.isOpenSource()) {
this.irmPlan = await makeRequest(`/usage-limits`, { method: 'GET' });
}
}
// methods were moved from rootBaseStore.
// TODO check if methods are dublicating existing ones
@action

View file

@ -90,23 +90,6 @@ export interface Alert {
has_pormortem?: boolean; // not implemented yet
}
export enum IRMPlanStatus {
WithinLimits = 'within-limits',
NearLimit = 'near-limit',
AtLimit = 'at-limit',
}
export interface ResponseIRMPlan {
limits: {
id: string;
irmProductStartDate: null;
isIrmPro: boolean;
status: IRMPlanStatus;
reasonHTML: string;
upgradeURL: string;
};
}
interface RenderForWeb {
message: any;
title: any;

View file

@ -2,7 +2,7 @@ import dayjs from 'dayjs';
import { RootStore } from 'state';
import { Event, Layer, Schedule, ScheduleType, Shift, ShiftEvents } from './schedule.types';
import { Event, Layer, Schedule, ScheduleType, Shift, ShiftEvents, ShiftSwap } from './schedule.types';
export const getFromString = (moment: dayjs.Dayjs) => {
return moment.format('YYYY-MM-DD');
@ -25,6 +25,25 @@ const createGap = (start, end) => {
};
};
export const createShiftSwapEventFromShiftSwap = (shiftSwap: Partial<ShiftSwap>) => {
return {
shiftSwapId: shiftSwap.id,
start: shiftSwap.swap_start,
end: shiftSwap.swap_end,
is_gap: false,
users: [],
all_day: false,
shift: null,
missing_users: [],
is_empty: true,
is_shift_swap: true,
calendar_type: ScheduleType.API,
priority_level: null,
source: 'web',
is_override: false,
};
};
export const fillGaps = (events: Event[]) => {
const newEvents = [];
@ -74,7 +93,7 @@ export const getShiftsFromStore = (
: (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as any);
};
export const flattenFinalShifs = (shifts: ShiftEvents[]) => {
export const flattenShiftEvents = (shifts: ShiftEvents[]) => {
if (!shifts) {
return undefined;
}
@ -193,6 +212,16 @@ export const getLayersFromStore = (store: RootStore, scheduleId: Schedule['id'],
: (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]);
};
export const getShiftSwapsFromStore = (
store: RootStore,
scheduleId: Schedule['id'],
startMoment: dayjs.Dayjs
): ShiftEvents[] => {
return store.scheduleStore.shiftSwapsPreview
? store.scheduleStore.shiftSwapsPreview[getFromString(startMoment)]
: store.scheduleStore.scheduleAndDateToShiftSwaps[scheduleId]?.[getFromString(startMoment)];
};
export const getOverridesFromStore = (
store: RootStore,
scheduleId: Schedule['id'],
@ -200,7 +229,7 @@ export const getOverridesFromStore = (
): ShiftEvents[] => {
return store.scheduleStore.overridePreview
? store.scheduleStore.overridePreview[getFromString(startMoment)]
: (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as Layer[]);
: (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as ShiftEvents[]);
};
export const splitToLayers = (

View file

@ -9,8 +9,10 @@ import { RootStore } from 'state';
import { SelectOption } from 'state/types';
import {
createShiftSwapEventFromShiftSwap,
enrichLayers,
enrichOverrides,
flattenShiftEvents,
getFromString,
splitToLayers,
splitToShiftsAndFillGaps,
@ -48,6 +50,9 @@ export class ScheduleStore extends BaseStore {
@observable.shallow
shiftSwaps: { [id: string]: ShiftSwap } = {};
@observable.shallow
scheduleAndDateToShiftSwaps: { [scheduleId: string]: { [date: string]: ShiftEvents[] } } = {};
@observable.shallow
rotations: {
[id: string]: {
@ -65,13 +70,18 @@ export class ScheduleStore extends BaseStore {
} = {};
@observable
finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>;
finalPreview?: { [fromString: string]: Array<{ shiftId: Shift['id']; events: Event[] }> };
@observable
rotationPreview?: Layer[];
rotationPreview?: { [fromString: string]: Layer[] };
@observable
overridePreview?: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>;
shiftSwapsPreview?: {
[fromString: string]: ShiftEvents[];
};
@observable
overridePreview?: { [fromString: string]: ShiftEvents[] };
@observable
rotationFormLiveParams: RotationFormLiveParams = undefined;
@ -105,24 +115,6 @@ export class ScheduleStore extends BaseStore {
return schedule;
}
@action
async updateScheduleEvents(
scheduleId: Schedule['id'],
withEmpty: boolean,
with_gap: boolean,
date: string,
user_tz: string
) {
const { events } = await makeRequest(`/schedules/${scheduleId}/events/`, {
params: { date, user_tz, with_empty: withEmpty, with_gap: with_gap },
});
this.scheduleToScheduleEvents = {
...this.scheduleToScheduleEvents,
[scheduleId]: events,
};
}
@action
async updateItems(
f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined },
@ -283,11 +275,36 @@ export class ScheduleStore extends BaseStore {
this.finalPreview = { ...this.finalPreview, [fromString]: splitToShiftsAndFillGaps(response.final) };
}
@action
async updateShiftsSwapPreview(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, params: Partial<ShiftSwap>) {
const fromString = getFromString(startMoment);
const newShiftEvents: ShiftEvents = {
shiftId: 'new',
events: [createShiftSwapEventFromShiftSwap(params)],
isPreview: true,
};
if (!this.scheduleAndDateToShiftSwaps[scheduleId][fromString]) {
await this.updateShiftSwaps(scheduleId, startMoment);
}
const existingShiftEventsList: ShiftEvents[] = this.scheduleAndDateToShiftSwaps[scheduleId][fromString];
const shiftEventsListFlattened = flattenShiftEvents([...existingShiftEventsList, newShiftEvents]);
this.shiftSwapsPreview = {
...this.shiftSwapsPreview,
[fromString]: shiftEventsListFlattened,
};
}
@action
clearPreview() {
this.finalPreview = undefined;
this.rotationPreview = undefined;
this.overridePreview = undefined;
this.shiftSwapsPreview = undefined;
this.rotationFormLiveParams = undefined;
}
@ -456,4 +473,42 @@ export class ScheduleStore extends BaseStore {
return result;
}
async updateShiftSwaps(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, days = 9) {
const fromString = getFromString(startMoment);
const dayBefore = startMoment.subtract(1, 'day');
const result = await makeRequest(`/schedules/${scheduleId}/filter_shift_swaps/`, {
method: 'GET',
params: {
date: getFromString(dayBefore),
days,
},
});
const shiftEventsList: ShiftEvents[] = result.shift_swaps.map((shiftSwap) => ({
shiftId: shiftSwap.id,
events: [createShiftSwapEventFromShiftSwap(shiftSwap)],
isPreview: false,
}));
const shiftEventsListFlattened = flattenShiftEvents(shiftEventsList);
this.shiftSwaps = result.shift_swaps.reduce(
(memo, shiftSwap) => ({
...memo,
[shiftSwap.id]: shiftSwap,
}),
this.shiftSwaps
);
this.scheduleAndDateToShiftSwaps = {
...this.scheduleAndDateToShiftSwaps,
[scheduleId]: {
...this.scheduleAndDateToShiftSwaps[scheduleId],
[fromString]: shiftEventsListFlattened,
},
};
}
}

View file

@ -103,6 +103,8 @@ export interface Event {
swap_request?: SwapRequest;
}>;
is_override: boolean;
shiftSwapId?: ShiftSwap['id']; // if event is acually shift swap request (filled out by frontend)
}
export interface Events {
@ -120,7 +122,7 @@ export interface Layer {
export interface ShiftEvents {
shiftId: string;
events: Event[];
priority: number;
priority?: number;
isPreview?: boolean;
}

View file

@ -6,9 +6,7 @@ import { observer } from 'mobx-react';
import gitHubStarSVG from 'assets/img/github_star.svg';
import logo from 'assets/img/logo.svg';
import Tag from 'components/Tag/Tag';
import Alerts from 'containers/Alerts/Alerts';
import IRMBanner from 'containers/IRMBanner/IRMBanner';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { useStore } from 'state/useStore';
import { APP_SUBTITLE } from 'utils/consts';
@ -58,13 +56,10 @@ const Header = observer(() => {
);
}
const { irmPlan } = store.alertGroupStore;
return (
<>
<HorizontalGroup>
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
{irmPlan?.limits && <Tag className={cx('irm-icon')}>{irmPlan.limits.isIrmPro ? 'IRM Pro' : 'IRM Lite'}</Tag>}
</HorizontalGroup>
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
</>
@ -76,7 +71,6 @@ const Banners: React.FC = () => {
return (
<div className={cx('banners')}>
<Alerts />
<IRMBanner />
</div>
);
};

View file

@ -62,7 +62,7 @@ const PagedUsers = observer((props: PagedUsersProps) => {
<li key={pagedUser.pk}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Avatar size="big" src={pagedUser.avatar} />
<Avatar size="medium" src={pagedUser.avatar} />
<Text strong>{pagedUser.username}</Text>
{Boolean(
storeUser &&

View file

@ -98,10 +98,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
private pollingIntervalId: NodeJS.Timer = undefined;
async componentDidMount() {
await this.props.store.alertGroupStore.fetchIRMPlan();
}
componentWillUnmount(): void {
this.clearPollingInterval();
}

View file

@ -140,13 +140,15 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
!isUserActionAllowed(UserActions.SchedulesWrite) ||
schedule?.type !== ScheduleType.API ||
!!shiftIdToShowRotationForm ||
shiftIdToShowOverridesForm;
shiftIdToShowOverridesForm ||
shiftSwapIdToShowForm;
const disabledOverrideForm =
!isUserActionAllowed(UserActions.SchedulesWrite) ||
!schedule?.enable_web_overrides ||
!!shiftIdToShowOverridesForm ||
shiftIdToShowRotationForm;
shiftIdToShowRotationForm ||
shiftSwapIdToShowForm;
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
@ -272,7 +274,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
disabled={disabledRotationForm}
onShowOverrideForm={this.handleShowOverridesForm}
filters={filters}
onShowShiftSwapForm={this.handleShowShiftSwapForm}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
onSlotClick={
shiftSwapIdToShowForm
? this.adjustShiftSwapForm
@ -293,7 +295,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
onShowOverrideForm={this.handleShowOverridesForm}
disabled={disabledRotationForm}
filters={filters}
onShowShiftSwapForm={this.handleShowShiftSwapForm}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
onSlotClick={shiftSwapIdToShowForm ? this.adjustShiftSwapForm : undefined}
/>
<ScheduleOverrides
@ -308,6 +310,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
disabled={disabledOverrideForm}
shiftStartToShowOverrideForm={shiftStartToShowOverrideForm}
shiftEndToShowOverrideForm={shiftEndToShowOverrideForm}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
filters={filters}
/>
</div>
@ -337,10 +340,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<ShiftSwapForm
id={shiftSwapIdToShowForm}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
params={shiftSwapParamsToShowForm}
onHide={this.handleHideShiftSwapForm}
onUpdate={this.updateEvents}
onUpdate={this.handleUpdateShiftSwaps}
/>
)}
</>
@ -426,6 +430,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'),
store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'),
store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'),
store.scheduleStore.updateShiftSwaps(scheduleId, startMoment),
]);
};
@ -453,6 +458,14 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
});
};
handleUpdateShiftSwaps = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.clearPreview();
});
};
handleDeleteRotation = () => {
const { store } = this.props;

View file

@ -369,7 +369,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} className="table__email-content">
<div className={cx('schedules__user-on-call')}>
<div>
<Avatar size="big" src={user.avatar} />
<Avatar size="medium" src={user.avatar} />
</div>
<MatchMediaTooltip placement="top" content={user.username} maxWidth={TABLE_COLUMN_MAX_WIDTH}>
<span className="table__email-content">{user.username}</span>

View file

@ -73,7 +73,7 @@ export const Root = observer((props: AppRootProps) => {
const [basicDataLoaded, setBasicDataLoaded] = useState(false);
useEffect(() => {
updateBasicData();
runQueuedUpdateData(0);
}, []);
const location = useLocation();
@ -98,11 +98,6 @@ export const Root = observer((props: AppRootProps) => {
};
}, []);
const updateBasicData = async () => {
await store.updateBasicData();
setBasicDataLoaded(true);
};
const page = getMatchedPage(location.pathname);
const pagePermissionAction = pages[page]?.action;
const userHasAccess = pagePermissionAction ? isUserActionAllowed(pagePermissionAction) : true;
@ -206,4 +201,17 @@ export const Root = observer((props: AppRootProps) => {
</div>
</DefaultPageLayout>
);
async function runQueuedUpdateData(attemptCount: number) {
if (attemptCount === 10) {
return;
}
try {
await store.updateBasicData();
setBasicDataLoaded(true);
} catch {
setTimeout(() => runQueuedUpdateData(attemptCount + 1), 1000);
}
}
});

View file

@ -133,7 +133,6 @@ export class RootBaseStore {
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
this.escalationPolicyStore.updateEscalationPolicyOptions(),
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),
this.alertGroupStore.fetchIRMPlan(),
]);
}