Merge branch 'dev' into 472-wrong-team

This commit is contained in:
Rares Mardare 2022-09-26 13:34:46 +03:00
commit ea54d21b76
138 changed files with 8451 additions and 482 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
*/db.sqlite3
*.pyc
venv
.python-version
.env
.env_hobby
.vscode

View file

@ -1,67 +1,99 @@
# Change Log
## v1.0.37 (2022-09-23)
- Improve API token creation form
- Fix alert group bulk action bugs
- Add `permalinks` property to `AlertGroup` public API response schema
- Scheduling system bug fixes
- Public API bug fixes
## v1.0.36 (2022-09-12)
- Alpha web schedules frontend/backend updates
- Bug fixes
## v1.0.35 (2022-09-07)
- Bug fixes
## v1.0.34 (2022-09-06)
- Fix schedule notification spam
## v1.0.33 (2022-09-06)
- Add raw alert view
- Add GitHub star button for OSS installations
- Restore alert group search functionality
- Bug fixes
## v1.0.32 (2022-09-01)
- Bug fixes
## v1.0.31 (2022-09-01)
- Bump celery version
- Fix oss to cloud connection
## v1.0.30 (2022-08-31)
- Bug fix: check user notification policy before access
## v1.0.29 (2022-08-31)
- Add arm64 docker image
## v1.0.28 (2022-08-31)
- Bug fixes
## v1.0.27 (2022-08-30)
- Bug fixes
## v1.0.26 (2022-08-26)
- Insight log's format fixes
- Remove UserNotificationPolicy auto-recreating
## v1.0.25 (2022-08-24)
- Bug fixes
## v1.0.24 (2022-08-24)
- Insight logs
- Default DATA_UPLOAD_MAX_MEMORY_SIZE to 1mb
## v1.0.23 (2022-08-23)
- Bug fixes
## v1.0.22 (2022-08-16)
- Make STATIC_URL configurable from environment variable
## v1.0.21 (2022-08-12)
- Bug fixes
## v1.0.19 (2022-08-10)
- Bug fixes
## v1.0.15 (2022-08-03)
- Bug fixes
## v1.0.13 (2022-07-27)
- Optimize alert group list view
- Fix a bug related to Twilio setup
## v1.0.12 (2022-07-26)
- Update push-notifications dependency
- Rework how absolute URLs are built
- Fix to show maintenance windows per team
@ -69,15 +101,18 @@
- Internal api to get a schedule final events
## v1.0.10 (2022-07-22)
- Speed-up of alert group web caching
- Internal api for OnCall shifts
## v1.0.9 (2022-07-21)
- Frontend bug fixes & improvements
- Support regex_replace() in templates
- Bring back alert group caching and list view
## v1.0.7 (2022-07-18)
- Backend & frontend bug fixes
- Deployment improvements
- Reshape webhook payload for outgoing webhooks
@ -85,18 +120,22 @@
- Improve alert group list load speeds and simplify caching system
## v1.0.6 (2022-07-12)
- Manual Incidents enabled for teams
- Fix phone notifications for OSS
- Public API improvements
## v1.0.5 (2022-07-06)
- Bump Django to 3.2.14
- Fix PagerDuty iCal parsing
## 1.0.4 (2022-06-28)
- Allow Telegram DMs without channel connection.
## 1.0.3 (2022-06-27)
- Fix users public api endpoint. Now it returns users with all roles.
- Fix redundant notifications about gaps in schedules.
- Frontend fixes.

View file

@ -1,22 +1,22 @@
* [Developer quickstart](#developer-quickstart)
* [Code style](#code-style)
* [Backend setup](#backend-setup)
* [Frontend setup](#frontend-setup)
* [Slack application setup](#slack-application-setup)
* [Update drone build](#update-drone-build)
* [Troubleshooting](#troubleshooting)
* [ld: library not found for -lssl](#ld-library-not-found-for--lssl)
* [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly)
* [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value-)
* [Empty queryset when filtering against datetime field](#empty-queryset-when-filtering-against-datetime-field)
* [Hints](#hints)
* [Building the all-in-one docker container](#building-the-all-in-one-docker-container)
* [Running Grafana with plugin (frontend) folder mounted for dev purposes](#running-grafana-with-plugin-frontend-folder-mounted-for-dev-purposes)
* [How to recreate the local database](#recreating-the-local-database)
* [Running tests locally](#running-tests-locally)
* [IDE Specific Instructions](#ide-specific-instructions)
* [PyCharm](#pycharm)
- [Developer quickstart](#developer-quickstart)
- [Code style](#code-style)
- [Backend setup](#backend-setup)
- [Frontend setup](#frontend-setup)
- [Slack application setup](#slack-application-setup)
- [Update drone build](#update-drone-build)
- [Troubleshooting](#troubleshooting)
- [ld: library not found for -lssl](#ld-library-not-found-for--lssl)
- [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly)
- [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value-)
- [Empty queryset when filtering against datetime field](#empty-queryset-when-filtering-against-datetime-field)
- [Hints](#hints)
- [Building the all-in-one docker container](#building-the-all-in-one-docker-container)
- [Running Grafana with plugin (frontend) folder mounted for dev purposes](#running-grafana-with-plugin-frontend-folder-mounted-for-dev-purposes)
- [How to recreate the local database](#recreating-the-local-database)
- [Running tests locally](#running-tests-locally)
- [IDE Specific Instructions](#ide-specific-instructions)
- [PyCharm](#pycharm)
## Developer quickstart
### Code style
@ -29,13 +29,23 @@
### Backend setup
1. Start stateful services (RabbitMQ, Redis, Grafana with mounted plugin folder)
```bash
docker-compose -f docker-compose-developer.yml up -d
```
NOTE: to use a PostgreSQL db backend, use the `docker-compose-developer-pg.yml` file instead.
2. Prepare a python environment:
2. `postgres` is a dependency on some of our Python dependencies (notably `psycopg2` ([docs](https://www.psycopg.org/docs/install.html#prerequisites))). To install this on Mac you can simply run:
```bash
brew install postgresql@14
```
For non Mac installation please visit [here](https://www.postgresql.org/download/) for more information on how to install.
3. Prepare a python environment:
```bash
# Create and activate the virtual environment
python3.9 -m venv venv && source venv/bin/activate
@ -67,8 +77,8 @@ python manage.py migrate
python manage.py createsuperuser
```
4. Launch the backend:
3. Launch the backend:
```bash
# Http server:
python manage.py runserver 0.0.0.0:8080
@ -80,14 +90,14 @@ python manage.py start_celery
celery -A engine beat -l info
```
4. All set! Check out internal API endpoints at http://localhost:8080/.
5. All set! Check out internal API endpoints at http://localhost:8000/.
### Frontend setup
1. Make sure you have [NodeJS v.14+ < 17](https://nodejs.org/) and [yarn](https://yarnpkg.com/) installed.
1. Make sure you have [NodeJS v.14+ < 17](https://nodejs.org/) and [yarn](https://yarnpkg.com/) installed. **Note**: If you are using [`nvm`](https://github.com/nvm-sh/nvm) feel free to simply run `cd grafana-plugin && nvm install` to install the proper Node version.
2. Install the dependencies with `yarn` and launch the frontend server (on port `3000` by default)
```bash
cd grafana-plugin
yarn install
@ -96,19 +106,21 @@ yarn watch
```
3. Ensure /grafana-plugin/provisioning has no grafana-plugin.yml
4. Generate an invitation token:
```bash
cd engine;
python manage.py issue_invite_for_the_frontend --override
```
... or use output of all-in-one docker container described in the README.md.
5. Open Grafana in the browser http://localhost:3000 (login: oncall, password: oncall) notice OnCall Plugin is not enabled, navigate to Configuration->Plugins and click Grafana OnCall
6. Some configuration fields will appear be available. Fill them out and click Initialize OnCall
```
OnCall API URL:
OnCall API URL:
http://host.docker.internal:8080
Invitation Token (Single use token to connect Grafana instance):
@ -120,6 +132,7 @@ http://localhost:3000
NOTE: you may not have `host.docker.internal` available, in that case you can get the
host IP from inside the container by running:
```bash
/sbin/ip route|awk '/default/ { print $3 }'
@ -133,13 +146,14 @@ extra_hosts:
For Slack app configuration check our docs: https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup
### Update drone build
The .drone.yml build file must be signed when changes are made to it. Follow these steps:
The .drone.yml build file must be signed when changes are made to it. Follow these steps:
If you have not installed drone CLI follow [these instructions](https://docs.drone.io/cli/install/)
To sign the .drone.yml file:
```bash
export DRONE_SERVER=https://drone.grafana.net
@ -154,6 +168,7 @@ drone sign --save grafana/oncall .drone.yml
### ld: library not found for -lssl
**Problem:**
```
pip install -r requirements.txt
...
@ -162,6 +177,7 @@ pip install -r requirements.txt
error: command 'gcc' failed with exit status 1
...
```
**Solution:**
```
@ -174,6 +190,7 @@ pip install -r requirements.txt
Happens on Apple Silicon
**Problem:**
```
build/temp.macosx-12-arm64-3.9/_openssl.c:575:10: fatal error: 'openssl/opensslv.h' file not found
#include <openssl/opensslv.h>
@ -183,7 +200,9 @@ Happens on Apple Silicon
----------------------------------------
ERROR: Failed building wheel for cryptography
```
**Solution:**
```
LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1.1)/include" pip install `cat requirements.txt | grep cryptography`
```
@ -191,6 +210,7 @@ LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1
### django.db.utils.OperationalError: (1366, "Incorrect string value ...")
**Problem:**
```
django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x98\\x8A\\xF0\\x9F...' for column 'cached_name' at row 1")
```
@ -198,15 +218,15 @@ django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x
**Solution:**
Recreate the database with the correct encoding.
### Grafana OnCall plugin does not show up in plugin list
### Grafana OnCall plugin does not show up in plugin list
**Problem:**
I've run `yarn watch` in `grafana_plugin` but I do not see Grafana OnCall in the list of plugins
**Solution:**
If it is the first time you have run `yarn watch` and it was run after starting Grafana in docker-compose; Grafana will not have detected a plugin to fix: `docker-compose -f developer-docker-compose.yml restart grafana`
## Hints:
### Building the all-in-one docker container
@ -219,9 +239,11 @@ docker build -t grafana/oncall-all-in-one -f Dockerfile.all-in-one .
### Running Grafana with plugin (frontend) folder mounted for dev purposes
Do it only after you built frontend at least once! Also developer-docker-compose.yml has similar Grafana included.
```bash
docker run --rm -it -p 3000:3000 -v "$(pwd)"/grafana-plugin:/var/lib/grafana/plugins/grafana-plugin -e GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=grafana-oncall-app --name=grafana grafana/grafana:8.3.2
```
Credentials: admin/admin
### Running tests locally
@ -239,10 +261,10 @@ pip install pytest.xdist
pytest -n4
```
## IDE Specific Instructions
### PyCharm
1. Create venv and copy .env file
```bash
python3.9 -m venv venv
@ -252,7 +274,7 @@ pytest -n4
3. Settings &rarr; Project OnCall
- In Python Interpreter click the gear and create a new Virtualenv from existing environment selecting the venv created in Step 1.
- In Project Structure make sure the project root is the content root and add /engine to Sources
4. Under Settings &rarr; Languages & Frameworks &rarr; Django
4. Under Settings &rarr; Languages & Frameworks &rarr; Django
- Enable Django support
- Set Django project root to /engine
- Set Settings to settings/dev.py

View file

@ -33,7 +33,10 @@ The above command returns JSON structured in the following way:
"created_at": "2020-05-19T12:37:01.430444Z",
"resolved_at": "2020-05-19T13:37:01.429805Z",
"acknowledged_at": null,
"title": "Memory above 90% threshold"
"title": "Memory above 90% threshold",
"permalinks": {
"slack": null
}
}
]
}

View file

@ -1,4 +1,5 @@
import logging
import typing
from collections import namedtuple
from typing import Optional
from urllib.parse import urljoin
@ -9,7 +10,7 @@ from celery import uuid as celery_uuid
from django.apps import apps
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import IntegrityError, models
from django.db import IntegrityError, models, transaction
from django.db.models import JSONField, Q, QuerySet
from django.utils import timezone
from django.utils.functional import cached_property
@ -45,6 +46,10 @@ def generate_public_primary_key_for_alert_group():
return new_public_primary_key
class Permalinks(typing.TypedDict):
slack: str
class AlertGroupQuerySet(models.QuerySet):
def create(self, **kwargs):
organization = kwargs["channel"].organization
@ -400,6 +405,13 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
if self.slack_message is not None:
return self.slack_message.permalink
@property
def permalinks(self) -> Permalinks:
# TODO: refactor 'permalink' property (maybe 'slack_permalink'?) once we add the next permalink
return {
"slack": self.permalink,
}
@property
def web_link(self):
return urljoin(self.channel.organization.web_link, f"?page=incident&id={self.public_primary_key}")
@ -978,18 +990,8 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
self.delete()
@staticmethod
def bulk_acknowledge(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
def _bulk_acknowledge(user: User, alert_groups_to_acknowledge: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
root_alert_groups_to_acknowledge = alert_groups.filter(
~Q(acknowledged=True, resolved=False), # don't need to ack acknowledged incidents once again
root_alert_group__isnull=True,
maintenance_uuid__isnull=True, # don't ack maintenance incident
)
# Find all dependent alert_groups to update them in one query
dependent_alert_groups_to_acknowledge = AlertGroup.all_objects.filter(
root_alert_group__in=root_alert_groups_to_acknowledge
)
alert_groups_to_acknowledge = root_alert_groups_to_acknowledge | dependent_alert_groups_to_acknowledge
# it is needed to unserolve those alert_groups which were resolved to build proper log.
alert_groups_to_unresolve_before_acknowledge = alert_groups_to_acknowledge.filter(resolved=True)
@ -1042,31 +1044,25 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
send_alert_group_signal.apply_async((log_record.pk,))
@staticmethod
def bulk_resolve(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
# stop maintenance for maintenance incidents
alert_groups_to_stop_maintenance = alert_groups.filter(resolved=False, maintenance_uuid__isnull=False)
for alert_group in alert_groups_to_stop_maintenance:
alert_group.stop_maintenance(user)
root_alert_groups_to_resolve = alert_groups.filter(
resolved=False,
def bulk_acknowledge(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
root_alert_groups_to_acknowledge = alert_groups.filter(
~Q(acknowledged=True, resolved=False), # don't need to ack acknowledged incidents once again
root_alert_group__isnull=True,
maintenance_uuid__isnull=True,
maintenance_uuid__isnull=True, # don't ack maintenance incident
)
if root_alert_groups_to_resolve.count() == 0:
return
# Find all dependent alert_groups to update them in one query
# convert qs to list to prevent changes by update
root_alert_group_pks = list(root_alert_groups_to_acknowledge.values_list("pk", flat=True))
dependent_alert_groups_to_acknowledge = AlertGroup.unarchived_objects.filter(
root_alert_group__pk__in=root_alert_group_pks
)
with transaction.atomic():
AlertGroup._bulk_acknowledge(user, root_alert_groups_to_acknowledge)
AlertGroup._bulk_acknowledge(user, dependent_alert_groups_to_acknowledge)
organization = root_alert_groups_to_resolve.first().channel.organization
if organization.is_resolution_note_required:
root_alert_groups_to_resolve = root_alert_groups_to_resolve.filter(
Q(resolution_notes__isnull=False, resolution_notes__deleted_at=None)
)
dependent_alert_groups_to_resolve = AlertGroup.all_objects.filter(
root_alert_group__in=root_alert_groups_to_resolve
)
alert_groups_to_resolve = root_alert_groups_to_resolve | dependent_alert_groups_to_resolve
@staticmethod
def _bulk_resolve(user: User, alert_groups_to_resolve: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
# it is needed to unsilence those alert_groups which were silenced to build proper log.
alert_groups_to_unsilence_before_resolve = alert_groups_to_resolve.filter(silenced=True)
@ -1098,41 +1094,76 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
send_alert_group_signal.apply_async((log_record.pk,))
@staticmethod
def bulk_restart(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
def bulk_resolve(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
# stop maintenance for maintenance incidents
alert_groups_to_stop_maintenance = alert_groups.filter(resolved=False, maintenance_uuid__isnull=False)
for alert_group in alert_groups_to_stop_maintenance:
alert_group.stop_maintenance(user)
root_alert_groups_to_resolve = alert_groups.filter(
resolved=False,
root_alert_group__isnull=True,
maintenance_uuid__isnull=True,
)
if not root_alert_groups_to_resolve.exists():
return
organization = root_alert_groups_to_resolve.first().channel.organization
if organization.is_resolution_note_required:
root_alert_groups_to_resolve = root_alert_groups_to_resolve.filter(
Q(resolution_notes__isnull=False, resolution_notes__deleted_at=None)
)
# convert qs to list to prevent changes by update
root_alert_group_pks = list(root_alert_groups_to_resolve.values_list("pk", flat=True))
dependent_alert_groups_to_resolve = AlertGroup.all_objects.filter(root_alert_group__pk__in=root_alert_group_pks)
with transaction.atomic():
AlertGroup._bulk_resolve(user, root_alert_groups_to_resolve)
AlertGroup._bulk_resolve(user, dependent_alert_groups_to_resolve)
@staticmethod
def _bulk_restart_unack(user: User, alert_groups_to_restart_unack: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
root_alert_groups_unack = alert_groups.filter(
resolved=False,
acknowledged=True,
root_alert_group__isnull=True,
maintenance_uuid__isnull=True, # don't restart maintenance incident
)
dependent_alert_groups_unack = AlertGroup.all_objects.filter(root_alert_group__in=root_alert_groups_unack)
alert_groups_to_restart_unack = root_alert_groups_unack | dependent_alert_groups_unack
root_alert_groups_unresolve = alert_groups.filter(resolved=True, root_alert_group__isnull=True)
dependent_alert_groups_unresolve = AlertGroup.all_objects.filter(
root_alert_group__in=root_alert_groups_unresolve
)
alert_groups_to_restart_unresolve = root_alert_groups_unresolve | dependent_alert_groups_unresolve
alert_groups_to_restart_unsilence = alert_groups.filter(
resolved=False,
acknowledged=False,
silenced=True,
root_alert_group__isnull=True,
)
# convert current qs to list to prevent changes by update
alert_groups_to_restart_unack_list = list(alert_groups_to_restart_unack)
alert_groups_to_restart_unresolve_list = list(alert_groups_to_restart_unresolve)
alert_groups_to_restart_unsilence_list = list(alert_groups_to_restart_unsilence)
alert_groups_to_restart = (
alert_groups_to_restart_unack | alert_groups_to_restart_unresolve | alert_groups_to_restart_unsilence
alert_groups_to_restart_unack.update(
acknowledged=False,
acknowledged_at=None,
acknowledged_by_user=None,
acknowledged_by=AlertGroup.NOT_YET,
resolved=False,
resolved_at=None,
is_open_for_grouping=None,
resolved_by_user=None,
resolved_by=AlertGroup.NOT_YET,
silenced_until=None,
silenced_by_user=None,
silenced_at=None,
silenced=False,
)
alert_groups_to_restart.update(
# unacknowledge alert groups
for alert_group in alert_groups_to_restart_unack_list:
log_record = alert_group.log_records.create(
type=AlertGroupLogRecord.TYPE_UN_ACK,
author=user,
reason="Bulk action restart",
)
if alert_group.is_root_alert_group:
alert_group.start_escalation_if_needed()
send_alert_group_signal.apply_async((log_record.pk,))
@staticmethod
def _bulk_restart_unresolve(user: User, alert_groups_to_restart_unresolve: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
# convert current qs to list to prevent changes by update
alert_groups_to_restart_unresolve_list = list(alert_groups_to_restart_unresolve)
alert_groups_to_restart_unresolve.update(
acknowledged=False,
acknowledged_at=None,
acknowledged_by_user=None,
@ -1161,18 +1192,28 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
send_alert_group_signal.apply_async((log_record.pk,))
# unacknowledge alert groups
for alert_group in alert_groups_to_restart_unack_list:
log_record = alert_group.log_records.create(
type=AlertGroupLogRecord.TYPE_UN_ACK,
author=user,
reason="Bulk action restart",
)
@staticmethod
def _bulk_restart_unsilence(user: User, alert_groups_to_restart_unsilence: "QuerySet[AlertGroup]") -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
if alert_group.is_root_alert_group:
alert_group.start_escalation_if_needed()
# convert current qs to list to prevent changes by update
alert_groups_to_restart_unsilence_list = list(alert_groups_to_restart_unsilence)
send_alert_group_signal.apply_async((log_record.pk,))
alert_groups_to_restart_unsilence.update(
acknowledged=False,
acknowledged_at=None,
acknowledged_by_user=None,
acknowledged_by=AlertGroup.NOT_YET,
resolved=False,
resolved_at=None,
is_open_for_grouping=None,
resolved_by_user=None,
resolved_by=AlertGroup.NOT_YET,
silenced_until=None,
silenced_by_user=None,
silenced_at=None,
silenced=False,
)
# unsilence alert groups
for alert_group in alert_groups_to_restart_unsilence_list:
@ -1184,7 +1225,38 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
send_alert_group_signal.apply_async((log_record.pk,))
@staticmethod
def bulk_silence(user: User, alert_groups: "QuerySet[AlertGroup]", silence_delay: int) -> None:
def bulk_restart(user: User, alert_groups: "QuerySet[AlertGroup]") -> None:
root_alert_groups_unack = alert_groups.filter(
resolved=False,
acknowledged=True,
root_alert_group__isnull=True,
maintenance_uuid__isnull=True, # don't restart maintenance incident
)
# convert qs to list to prevent changes by update
root_alert_group_pks = list(root_alert_groups_unack.values_list("pk", flat=True))
dependent_alert_groups_unack = AlertGroup.all_objects.filter(root_alert_group__pk__in=root_alert_group_pks)
with transaction.atomic():
AlertGroup._bulk_restart_unack(user, root_alert_groups_unack)
AlertGroup._bulk_restart_unack(user, dependent_alert_groups_unack)
root_alert_groups_unresolve = alert_groups.filter(resolved=True, root_alert_group__isnull=True)
# convert qs to list to prevent changes by update
root_alert_group_pks = list(root_alert_groups_unresolve.values_list("pk", flat=True))
dependent_alert_groups_unresolve = AlertGroup.all_objects.filter(root_alert_group__pk__in=root_alert_group_pks)
with transaction.atomic():
AlertGroup._bulk_restart_unresolve(user, root_alert_groups_unresolve)
AlertGroup._bulk_restart_unresolve(user, dependent_alert_groups_unresolve)
alert_groups_to_restart_unsilence = alert_groups.filter(
resolved=False,
acknowledged=False,
silenced=True,
root_alert_group__isnull=True,
)
AlertGroup._bulk_restart_unsilence(user, alert_groups_to_restart_unsilence)
@staticmethod
def _bulk_silence(user: User, alert_groups_to_silence: "QuerySet[AlertGroup]", silence_delay: int) -> None:
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
now = timezone.now()
@ -1197,12 +1269,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
silence_delay_timedelta = None
silenced_until = None
root_alert_groups_to_silence = alert_groups.filter(
root_alert_group__isnull=True,
maintenance_uuid__isnull=True, # don't silence maintenance incident
)
dependent_alert_groups_to_silence = alert_groups.filter(root_alert_group__in=root_alert_groups_to_silence)
alert_groups_to_silence = root_alert_groups_to_silence | dependent_alert_groups_to_silence
alert_groups_to_unsilence_before_silence = alert_groups_to_silence.filter(
silenced=True, acknowledged=False, resolved=False
)
@ -1280,6 +1346,19 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
if silence_for_period and alert_group.is_root_alert_group:
alert_group.start_unsilence_task(countdown=silence_delay)
@staticmethod
def bulk_silence(user: User, alert_groups: "QuerySet[AlertGroup]", silence_delay: int) -> None:
root_alert_groups_to_silence = alert_groups.filter(
root_alert_group__isnull=True,
maintenance_uuid__isnull=True, # don't silence maintenance incident
)
# convert qs to list to prevent changes by update
root_alert_group_pks = list(root_alert_groups_to_silence.values_list("pk", flat=True))
dependent_alert_groups_to_silence = alert_groups.filter(root_alert_group__pk__in=root_alert_group_pks)
with transaction.atomic():
AlertGroup._bulk_silence(user, root_alert_groups_to_silence, silence_delay)
AlertGroup._bulk_silence(user, dependent_alert_groups_to_silence, silence_delay)
def start_ack_reminder(self, user: User):
Organization = apps.get_model("user_management", "Organization")
unique_unacknowledge_process_id = uuid1()

View file

@ -12,3 +12,11 @@ def test_request_outgoing_webhook_cannot_resolve_name():
success, err = request_outgoing_webhook("http://something.something/webhook", "GET")
assert success is False
assert err == "Cannot resolve name in url"
@pytest.mark.django_db
def test_request_outgoing_webhook_resolve_name_without_port():
with patch("apps.alerts.utils.socket.gethostbyname") as mock_gethostbyname:
mock_gethostbyname.return_value = "127.0.0.1"
request_outgoing_webhook("http://something.something:9000/webhook", "GET")
assert mock_gethostbyname.call_args_list[0].args[0] == "something.something"

View file

@ -57,7 +57,7 @@ def request_outgoing_webhook(webhook_url, http_request_type, post_kwargs={}) ->
if not live_settings.DANGEROUS_WEBHOOKS_ENABLED:
# Get the ip address of the webhook url and check if it belongs to the private network
try:
webhook_url_ip_address = socket.gethostbyname(parsed_url.netloc)
webhook_url_ip_address = socket.gethostbyname(parsed_url.hostname)
except socket.gaierror:
return False, "Cannot resolve name in url"
if not live_settings.DANGEROUS_WEBHOOKS_ENABLED:

View file

@ -70,10 +70,6 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
result = super().to_internal_value(data)
return result
def to_representation(self, instance):
result = super().to_representation(instance)
return result
def validate_by_day(self, by_day):
if by_day:
for day in by_day:

View file

@ -1,3 +1,4 @@
import math
import time
import pytz
@ -62,6 +63,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"permissions",
"notification_chain_verbal",
"cloud_connection_status",
"hide_phone_number",
]
read_only_fields = [
"email",
@ -155,6 +157,24 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
return status
return None
def to_representation(self, instance):
result = super().to_representation(instance)
if instance.id != self.context["request"].user.id:
if instance.hide_phone_number:
if result["verified_phone_number"]:
result["verified_phone_number"] = self._hide_phone_number(result["verified_phone_number"])
if result["unverified_phone_number"]:
result["unverified_phone_number"] = self._hide_phone_number(result["unverified_phone_number"])
return result
@staticmethod
def _hide_phone_number(number: str):
HIDE_SYMBOL = "*"
SHOW_LAST_SYMBOLS = 4
if len(number) <= 4:
SHOW_LAST_SYMBOLS = math.ceil(len(number) / 2)
return f"{HIDE_SYMBOL * (len(number) - SHOW_LAST_SYMBOLS)}{number[-SHOW_LAST_SYMBOLS:]}"
class UserHiddenFieldsSerializer(UserSerializer):
available_for_all_roles_fields = [
@ -171,10 +191,11 @@ class UserHiddenFieldsSerializer(UserSerializer):
def to_representation(self, instance):
ret = super(UserSerializer, self).to_representation(instance)
for field in ret:
if field not in self.available_for_all_roles_fields:
ret[field] = "******"
ret["hidden_fields"] = True
if instance.id != self.context["request"].user.id:
for field in ret:
if field not in self.available_for_all_roles_fields:
ret[field] = "******"
ret["hidden_fields"] = True
return ret

View file

@ -412,8 +412,9 @@ def test_update_old_on_call_shift_with_future_version(
token, user1, user2, organization, schedule = on_call_shift_internal_api_setup
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(days=3)).replace(microsecond=0)
next_rotation_start_date = start_date + timezone.timedelta(days=5)
now = timezone.now().replace(microsecond=0)
start_date = now - timezone.timedelta(days=3)
next_rotation_start_date = now + timezone.timedelta(days=1)
updated_duration = timezone.timedelta(hours=4)
title = "Test Shift Rotation"
@ -422,10 +423,11 @@ def test_update_old_on_call_shift_with_future_version(
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
start=start_date,
start=next_rotation_start_date,
duration=timezone.timedelta(hours=3),
rotation_start=next_rotation_start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
frequency=CustomOnCallShift.FREQUENCY_DAILY,
)
old_on_call_shift = make_on_call_shift(
schedule.organization,
@ -438,6 +440,7 @@ def test_update_old_on_call_shift_with_future_version(
until=next_rotation_start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
updated_shift=new_on_call_shift,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
)
# update shift_end and priority_level
data_to_update = {
@ -445,9 +448,9 @@ def test_update_old_on_call_shift_with_future_version(
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + updated_duration).strftime("%Y-%m-%dT%H:%M:%SZ"),
"rotation_start": next_rotation_start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"rotation_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"until": None,
"frequency": None,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"interval": None,
"by_day": None,
"rolling_users": [[user1.public_primary_key]],
@ -461,6 +464,11 @@ def test_update_old_on_call_shift_with_future_version(
url = reverse("api-internal:oncall_shifts-detail", kwargs={"pk": old_on_call_shift.public_primary_key})
response = client.put(url, data=data_to_update, format="json", **make_user_auth_headers(user1, token))
response_data = response.json()
for key in ["shift_start", "shift_end", "rotation_start"]:
data_to_update.pop(key)
response_data.pop(key)
expected_payload = data_to_update | {
"id": new_on_call_shift.public_primary_key,
@ -472,10 +480,12 @@ def test_update_old_on_call_shift_with_future_version(
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_payload
new_on_call_shift.refresh_from_db()
# check if the newest version of shift was changed
assert old_on_call_shift.duration != updated_duration
assert old_on_call_shift.priority_level != data_to_update["priority_level"]
new_on_call_shift.refresh_from_db()
# check if the newest version of shift was changed
assert new_on_call_shift.start - now < timezone.timedelta(minutes=1)
assert new_on_call_shift.rotation_start - now < timezone.timedelta(minutes=1)
assert new_on_call_shift.duration == updated_duration
assert new_on_call_shift.priority_level == data_to_update["priority_level"]
@ -1511,7 +1521,7 @@ def test_on_call_shift_preview_update(
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
request_date = start_date
tomorrow = now + timezone.timedelta(days=1)
user = make_user_for_organization(organization)
other_user = make_user_for_organization(organization)
@ -1530,11 +1540,9 @@ def test_on_call_shift_preview_update(
)
on_call_shift.add_rolling_users([[user]])
url = "{}?date={}&days={}".format(
reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1
)
shift_start = (start_date + timezone.timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_end = (start_date + timezone.timedelta(hours=18)).strftime("%Y-%m-%dT%H:%M:%SZ")
url = "{}?date={}&days={}".format(reverse("api-internal:oncall_shifts-preview"), tomorrow.strftime("%Y-%m-%d"), 1)
shift_start = (tomorrow + timezone.timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_end = (tomorrow + timezone.timedelta(hours=18)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_data = {
"schedule": schedule.public_primary_key,
"shift_pk": on_call_shift.public_primary_key,
@ -1551,39 +1559,43 @@ def test_on_call_shift_preview_update(
# check rotation events
rotation_events = response.json()["rotation"]
assert len(rotation_events) == 4
# the final original rotation events are returned and the ID is kept
for shift in rotation_events[:3]:
assert shift["shift"]["pk"] == on_call_shift.public_primary_key
# previewing an update does not reuse shift PK if rotation already started
shift_pk = rotation_events[0]["shift"]["pk"]
assert shift_pk != on_call_shift.public_primary_key
expected_rotation_events = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"shift": {"pk": shift_pk},
"start": shift_start,
"end": shift_end,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": 1,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"source": "web",
},
]
assert rotation_events == expected_rotation_events
new_shift_pk = rotation_events[-1]["shift"]["pk"]
assert new_shift_pk != on_call_shift.public_primary_key
expected_shift_preview = {
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"shift": {"pk": new_shift_pk},
"start": shift_start,
"end": shift_end,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": 1,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"source": "web",
}
assert rotation_events[-1] == expected_shift_preview
# check final schedule events
final_events = response.json()["final"]
expected = (
# start (h), duration (H), user, priority
(0, 1, user.username, 1), # 0-1 user
(4, 1, user.username, 1), # 4-5 user
(8, 1, user.username, 1), # 8-9 user
(10, 8, other_user.username, 1), # 10-18 other_user
)
expected_events = [
{
"end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"end": (tomorrow + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"priority_level": priority,
"start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime(
"start": (tomorrow + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
"user": user,

View file

@ -66,6 +66,7 @@ def test_update_user_cant_change_email_and_username(
"organization": {"pk": organization.public_primary_key, "name": organization.org_title},
"current_team": None,
"email": admin.email,
"hide_phone_number": False,
"username": admin.username,
"role": admin.role,
"timezone": None,
@ -114,6 +115,7 @@ def test_list_users(
"organization": {"pk": organization.public_primary_key, "name": organization.org_title},
"current_team": None,
"email": admin.email,
"hide_phone_number": False,
"username": admin.username,
"role": admin.role,
"timezone": None,
@ -137,6 +139,7 @@ def test_list_users(
"organization": {"pk": organization.public_primary_key, "name": organization.org_title},
"current_team": None,
"email": editor.email,
"hide_phone_number": False,
"username": editor.username,
"role": editor.role,
"timezone": None,

View file

@ -182,12 +182,10 @@ class UserView(
if self.action in ["list"] and is_filters_request:
return self.get_filter_serializer_class()
is_user_retrieves_own_data = (
self.action == "retrieve"
and self.kwargs.get("pk") is not None
and self.kwargs.get("pk") == self.request.user.public_primary_key
is_users_own_data = (
self.kwargs.get("pk") is not None and self.kwargs.get("pk") == self.request.user.public_primary_key
)
if is_user_retrieves_own_data or self.request.user.role == Role.ADMIN:
if is_users_own_data or self.request.user.role == Role.ADMIN:
return UserSerializer
return UserHiddenFieldsSerializer

View file

@ -2,7 +2,9 @@ import json
import re
from urllib.parse import urlparse
import phonenumbers
from django.apps import apps
from phonenumbers import NumberParseException
from python_http_client import UnauthorizedError
from sendgrid import SendGridAPIClient
from telegram import Bot
@ -125,7 +127,11 @@ class LiveSettingValidator:
@staticmethod
def _is_phone_number_valid(phone_number):
return re.match(r"^\+\d{11}$", phone_number)
try:
ph_num = phonenumbers.parse(phone_number)
return phonenumbers.is_valid_number(ph_num)
except NumberParseException:
return False
@staticmethod
def _prettify_twilio_error(exc):

View file

@ -121,6 +121,8 @@ class GrafanaAPIClient(APIClient):
class GcomAPIClient(APIClient):
ACTIVE_INSTANCE_QUERY = "instances?status=active"
DELETED_INSTANCE_QUERY = "instances?status=deleted&includeDeleted=true"
STACK_STATUS_DELETED = "deleted"
def __init__(self, api_token: str):
@ -132,8 +134,8 @@ class GcomAPIClient(APIClient):
def get_instance_info(self, stack_id: str):
return self.api_get(f"instances/{stack_id}")
def get_active_instances(self):
return self.api_get("instances?status=active")
def get_instances(self, query: str):
return self.api_get(query)
def is_stack_deleted(self, stack_id: str) -> bool:
instance_info, call_status = self.get_instance_info(stack_id)

View file

@ -89,15 +89,23 @@ def check_token(token_string: str, context: dict):
return PluginAuthToken.validate_token_string(token_string, context=context)
def get_active_instance_ids() -> Tuple[Optional[set], bool]:
def get_instance_ids(query: str) -> Tuple[Optional[set], bool]:
if not settings.GRAFANA_COM_API_TOKEN or settings.LICENSE != settings.CLOUD_LICENSE_NAME:
return None, False
client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN)
active_instances, status = client.get_active_instances()
instances, status = client.get_instances(query)
if not active_instances:
if not instances:
return None, True
active_ids = set(i["id"] for i in active_instances["items"])
return active_ids, True
ids = set(i["id"] for i in instances["items"])
return ids, True
def get_active_instance_ids() -> Tuple[Optional[set], bool]:
return get_instance_ids(GcomAPIClient.ACTIVE_INSTANCE_QUERY)
def get_deleted_instance_ids() -> Tuple[Optional[set], bool]:
return get_instance_ids(GcomAPIClient.DELETED_INSTANCE_QUERY)

View file

@ -5,9 +5,9 @@ from django.conf import settings
from django.utils import timezone
from apps.grafana_plugin.helpers import GcomAPIClient
from apps.grafana_plugin.helpers.gcom import get_active_instance_ids
from apps.grafana_plugin.helpers.gcom import get_active_instance_ids, get_deleted_instance_ids
from apps.user_management.models import Organization
from apps.user_management.sync import sync_organization
from apps.user_management.sync import cleanup_organization, sync_organization
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
@ -16,6 +16,7 @@ logger.setLevel(logging.DEBUG)
# celery beat will schedule start_sync_organizations for every 30 minutes
# to make sure that orgs are synced every 30 minutes, SYNC_PERIOD should be a little lower
SYNC_PERIOD = timezone.timedelta(minutes=25)
INACTIVE_PERIOD = timezone.timedelta(minutes=55)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=5)
@ -35,7 +36,7 @@ def start_sync_organizations():
organization_pks = organization_qs.values_list("pk", flat=True)
max_countdown = 25 * 60 # SYNC_PERIOD minutes -> Seconds
max_countdown = SYNC_PERIOD.seconds
for idx, organization_pk in enumerate(organization_pks):
countdown = idx % max_countdown # Spread orgs evenly along SYNC_PERIOD
sync_organization_async.apply_async((organization_pk,), countdown=countdown)
@ -73,3 +74,32 @@ def run_organization_sync(organization_pk, force_sync):
sync_organization(organization)
logger.info(f"Finish sync Organization {organization_pk}")
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1)
def start_cleanup_deleted_organizations():
sync_threshold = timezone.now() - INACTIVE_PERIOD
organization_qs = Organization.objects.filter(last_time_synced__lte=sync_threshold)
deleted_instance_ids, is_cloud_configured = get_deleted_instance_ids()
if is_cloud_configured:
if not deleted_instance_ids:
logger.warning("Did not find any deleted instances!")
return
else:
logger.debug(f"Found {len(deleted_instance_ids)} deleted instances")
organization_qs = organization_qs.filter(stack_id__in=deleted_instance_ids)
organization_pks = organization_qs.values_list("pk", flat=True)
logger.debug(f"Found {len(organization_pks)} deleted organizations not synced recently")
max_countdown = INACTIVE_PERIOD.seconds
for idx, organization_pk in enumerate(organization_pks):
countdown = idx % max_countdown # Spread orgs evenly
cleanup_organization_async.apply_async((organization_pk,), countdown=countdown)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1)
def cleanup_organization_async(organization_pk):
cleanup_organization(organization_pk)

View file

@ -14,7 +14,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
title = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
SELECT_RELATED = ["channel", "channel_filter"]
SELECT_RELATED = ["channel", "channel_filter", "slack_message"]
PREFETCH_RELATED = ["alerts"]
class Meta:
@ -29,6 +29,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"resolved_at",
"acknowledged_at",
"title",
"permalinks",
]
def get_alerts_count(self, obj):

View file

@ -46,10 +46,15 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer):
def validate_shifts(self, shifts):
# Get team_id from instance, if it exists, otherwise get it from initial data.
# Terraform sends empty string instead of None. In this case change team_id value to None.
team_id = self.instance.team_id if self.instance else (self.initial_data.get("team_id") or None)
if self.instance and self.instance.team:
team_id = self.instance.team.public_primary_key
else:
# Terraform sends empty string instead of None. In this case change team_id value to None.
team_id = self.initial_data.get("team_id") or None
for shift in shifts:
if shift.team_id != team_id:
shift_team_id = shift.team.public_primary_key if shift.team else None
if shift_team_id != team_id:
raise BadRequest(detail="Shifts must be assigned to the same team as the schedule")
if shift.type == CustomOnCallShift.TYPE_OVERRIDE:
raise BadRequest(detail="Shifts of type override are not supported in this schedule")

View file

@ -1,4 +1,5 @@
from unittest import mock
from unittest.mock import patch
import pytest
from django.urls import reverse
@ -38,6 +39,9 @@ def construct_expected_response_from_incidents(incidents):
"resolved_at": resolved_at,
"acknowledged_at": acknowledged_at,
"title": None,
"permalinks": {
"slack": None,
},
}
)
expected_response = {"count": incidents.count(), "next": None, "previous": None, "results": results}
@ -183,6 +187,24 @@ def test_delete_incident_invalid_request(incident_public_api_setup):
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_pagination(settings, incident_public_api_setup):
settings.BASE_URL = "https://test.com/test/prefixed/urls"
token, incidents, _, _ = incident_public_api_setup
client = APIClient()
url = reverse("api-public:alert_groups-list")
with patch("common.api_helpers.paginators.PathPrefixedPagination.get_page_size", return_value=1):
response = client.get(url, HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
result = response.json()
assert result["next"].startswith("https://test.com/test/prefixed/urls")
# This is test from old django-based tests
# TODO: uncomment with date checking in delete mode
# def test_delete_incident_invalid_date(self):

View file

@ -93,6 +93,56 @@ def test_create_calendar_schedule(make_organization_and_user_with_token):
assert response.json() == result
@pytest.mark.django_db
def test_create_calendar_schedule_with_shifts(make_organization_and_user_with_token, make_team, make_on_call_shift):
organization, user, token = make_organization_and_user_with_token()
team = make_team(organization)
# request user must belong to the team
team.users.add(user)
client = APIClient()
start_date = timezone.datetime.now().replace(microsecond=0)
data = {
"team": team,
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(seconds=10800),
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data
)
url = reverse("api-public:schedules-list")
data = {
"team_id": team.public_primary_key,
"name": "schedule test name",
"time_zone": "Europe/Moscow",
"type": "calendar",
"shifts": [on_call_shift.public_primary_key],
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"])
result = {
"id": schedule.public_primary_key,
"team_id": team.public_primary_key,
"name": schedule.name,
"type": "calendar",
"time_zone": "Europe/Moscow",
"on_call_now": [],
"shifts": [on_call_shift.public_primary_key],
"slack": {
"channel_id": None,
"user_group_id": None,
},
"ical_url_overrides": None,
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == result
@pytest.mark.django_db
def test_update_calendar_schedule(
make_organization_and_user_with_token,

View file

@ -9,6 +9,6 @@ ICAL_ATTENDEE = "ATTENDEE"
ICAL_UID = "UID"
ICAL_RRULE = "RRULE"
ICAL_UNTIL = "UNTIL"
RE_PRIORITY = re.compile(r"^\[L(\d)\]")
RE_PRIORITY = re.compile(r"^\[L(\d+)\]")
RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)")
RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)")

View file

@ -134,7 +134,7 @@ def list_of_oncall_shifts_from_ical(
continue
tmp_result_datetime, tmp_result_date = get_shifts_dict(
calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts
calendar, calendar_type, schedule, datetime_start, datetime_end, with_empty_shifts
)
result_datetime.extend(tmp_result_datetime)
result_date.extend(tmp_result_date)
@ -161,7 +161,7 @@ def list_of_oncall_shifts_from_ical(
return result or None
def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts=False):
def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, with_empty_shifts=False):
events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end)
result_datetime = []
result_date = []
@ -175,25 +175,10 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
if type(event[ICAL_DATETIME_START].dt) == datetime.date:
start = event[ICAL_DATETIME_START].dt
end = event[ICAL_DATETIME_END].dt
if start <= date < end:
result_date.append(
{
"start": start,
"end": end,
"users": users,
"missing_users": missing_users,
"priority": priority,
"source": source,
"calendar_type": calendar_type,
"shift_pk": pk,
}
)
else:
start, end = ical_events.get_start_and_end_with_respect_to_event_type(event)
result_datetime.append(
result_date.append(
{
"start": start.astimezone(pytz.UTC),
"end": end.astimezone(pytz.UTC),
"start": start,
"end": end,
"users": users,
"missing_users": missing_users,
"priority": priority,
@ -202,6 +187,21 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
"shift_pk": pk,
}
)
else:
start, end = ical_events.get_start_and_end_with_respect_to_event_type(event)
if start < end:
result_datetime.append(
{
"start": start.astimezone(pytz.UTC),
"end": end.astimezone(pytz.UTC),
"users": users,
"missing_users": missing_users,
"priority": priority,
"source": source,
"calendar_type": calendar_type,
"shift_pk": pk,
}
)
return result_datetime, result_date

View file

@ -433,6 +433,34 @@ class CustomOnCallShift(models.Model):
return
return next_event_dt
def get_last_event_date(self, date):
"""Get start date of the last event before the chosen date"""
assert date >= self.start, "Chosen date should be later or equal to initial event start date"
event_ical = self.generate_ical(self.start)
initial_event = Event.from_ical(event_ical)
# take shift interval, not event interval. For rolling_users shift it is not the same.
interval = self.interval or 1
if "rrule" in initial_event:
# means that shift has frequency
initial_event["rrule"]["INTERVAL"] = interval
initial_event_start = initial_event["DTSTART"].dt
last_event = None
# repetitions generate the next event shift according with the recurrence rules
repetitions = UnfoldableCalendar(initial_event).RepeatedEvent(
initial_event, initial_event_start.replace(microsecond=0)
)
ical_iter = repetitions.__iter__()
for event in ical_iter:
if event.start > date:
break
last_event = event
last_event_dt = last_event.start if last_event else initial_event_start
return last_event_dt
@cached_property
def event_ical_rules(self):
# e.g. {'freq': ['WEEKLY'], 'interval': [2], 'byday': ['MO', 'WE', 'FR'], 'wkst': ['SU']}
@ -441,7 +469,7 @@ class CustomOnCallShift(models.Model):
rules["freq"] = [self.get_frequency_display().upper()]
if self.event_interval is not None:
rules["interval"] = [self.event_interval]
if self.by_day is not None:
if self.by_day:
rules["byday"] = self.by_day
if self.by_month is not None:
rules["bymonth"] = self.by_month
@ -498,6 +526,37 @@ class CustomOnCallShift(models.Model):
self.rolling_users = result
self.save(update_fields=["rolling_users"])
def get_rotation_user_index(self, date):
START_ROTATION_INDEX = 0
result = START_ROTATION_INDEX
if not self.rolling_users or self.frequency is None:
return START_ROTATION_INDEX
# generate initial iCal for counting rotation start date
event_ical = self.generate_ical(self.start, user_counter=0)
# Get the date of the current rotation
if self.start == self.rotation_start:
start = self.start
else:
start = self.get_rotation_date(event_ical)
if not start or start >= date:
return START_ROTATION_INDEX
# count how many times the rotation was triggered before the selected date
while start or start < date:
start = self.get_rotation_date(event_ical, get_next_date=True)
if not start or start >= date:
break
event_ical = self.generate_ical(start, user_counter=0)
result += 1
result %= len(self.rolling_users)
return result
def start_drop_ical_and_check_schedule_tasks(self, schedule):
drop_cached_ical_task.apply_async((schedule.pk,))
schedule_notify_about_empty_shifts_in_schedule.apply_async((schedule.pk,))
@ -512,8 +571,9 @@ class CustomOnCallShift(models.Model):
return last_shift
def create_or_update_last_shift(self, data):
now = timezone.now().replace(microsecond=0)
# rotation start date cannot be earlier than now
data["rotation_start"] = max(data["rotation_start"], timezone.now().replace(microsecond=0))
data["rotation_start"] = max(data["rotation_start"], now)
# prepare dict with params of existing instance with last updates and remove unique and m2m fields from it
shift_to_update = self.last_updated_shift or self
instance_data = model_to_dict(shift_to_update)
@ -524,6 +584,14 @@ class CustomOnCallShift(models.Model):
instance_data.update(data)
instance_data["schedule"] = self.schedule
instance_data["team"] = self.team
# set new event start date to keep rotation index
if instance_data["start"] == self.start:
instance_data["start"] = self.get_last_event_date(now)
# calculate rotation index to keep user rotation order
start_rotation_from_user_index = self.get_rotation_user_index(now) + (self.start_rotation_from_user_index or 0)
if start_rotation_from_user_index >= len(instance_data["rolling_users"]):
start_rotation_from_user_index = 0
instance_data["start_rotation_from_user_index"] = start_rotation_from_user_index
if self.last_updated_shift is None or self.last_updated_shift.event_is_started:
# create new shift

View file

@ -3,6 +3,7 @@ import functools
import itertools
import icalendar
import pytz
from django.apps import apps
from django.conf import settings
from django.core.validators import MinLengthValidator
@ -254,12 +255,20 @@ class OnCallSchedule(PolymorphicModel):
if not events:
return []
def event_start_cmp_key(e):
# all day events: compare using a datetime object at 00:00
start = e["start"]
if not isinstance(start, datetime.datetime):
start = datetime.datetime.combine(start, datetime.datetime.min.time(), tzinfo=pytz.UTC)
return start
def event_cmp_key(e):
"""Sorting key criteria for events."""
start = event_start_cmp_key(e)
return (
-e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None
-e["priority_level"] if e["priority_level"] else 0,
e["start"],
start,
)
def insort_event(eventlist, e):
@ -314,7 +323,7 @@ class OnCallSchedule(PolymorphicModel):
if ev["priority_level"] != current_priority:
# update scheduled intervals on priority change
# and start from the beginning for the new priority level
resolved.sort(key=lambda e: e["start"])
resolved.sort(key=event_start_cmp_key)
intervals = _merge_intervals(resolved)
current_interval_idx = 0
current_priority = ev["priority_level"]
@ -367,7 +376,7 @@ class OnCallSchedule(PolymorphicModel):
# TODO: switch to bisect insert on python 3.10 (or consider heapq)
insort_event(pending, ev)
resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"]))
resolved.sort(key=lambda e: (event_start_cmp_key(e), e["shift"]["pk"] or ""))
return resolved
def _merge_events(self, events):
@ -676,6 +685,10 @@ class OnCallScheduleWeb(OnCallSchedule):
pass
else:
if update_shift.event_is_started:
custom_shift.rotation_start = max(
custom_shift.rotation_start, timezone.now().replace(microsecond=0)
)
custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index
update_shift.until = custom_shift.rotation_start
extra_shifts.append(update_shift)
else:
@ -691,7 +704,9 @@ class OnCallScheduleWeb(OnCallSchedule):
# filter events using a temporal overriden calendar including the not-yet-saved shift
events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True)
shift_events = [e for e in events if e["shift"]["pk"] == custom_shift.public_primary_key]
# return preview events for affected shifts
updated_shift_pks = {s.public_primary_key for s in extra_shifts}
shift_events = [e for e in events if e["shift"]["pk"] in updated_shift_pks]
final_events = self._resolve_schedule(events)
_invalidate_cache(self, ical_property)

View file

@ -1,9 +1,16 @@
import datetime
from uuid import uuid4
import pytest
import pytz
from django.utils import timezone
from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical
from apps.schedules.ical_utils import (
list_of_oncall_shifts_from_ical,
list_users_to_notify_from_ical,
parse_event_uid,
users_in_ical,
)
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar
from common.constants.role import Role
@ -63,6 +70,29 @@ def test_list_users_to_notify_from_ical_viewers_inclusion(
assert set(users_on_call) == {user}
@pytest.mark.django_db
def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ical):
calendar = get_ical("calendar_with_all_day_event.ics")
organization = make_organization()
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
schedule.cached_ical_file_primary = calendar.to_ical()
day_to_check_iso = "2021-01-27T15:27:14.448059+00:00"
parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC)
requested_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date()
shifts = list_of_oncall_shifts_from_ical(schedule, requested_date, days=3, with_empty_shifts=True)
assert len(shifts) == 4
for s in shifts:
start = s["start"].date() if isinstance(s["start"], datetime.datetime) else s["start"]
end = s["end"].date() if isinstance(s["end"], datetime.datetime) else s["end"]
# event started in the given period, or ended in that period, or is happening during the period
assert (
requested_date <= start <= requested_date + timezone.timedelta(days=3)
or requested_date <= end <= requested_date + timezone.timedelta(days=3)
or start <= requested_date <= end
)
def test_parse_event_uid_v1():
uuid = uuid4()
event_uid = f"amixr-{uuid}-U1-E2-S1"

View file

@ -1,7 +1,10 @@
import datetime
import pytest
import pytz
from django.utils import timezone
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb
from common.constants.role import Role
@ -225,6 +228,33 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati
assert events == expected
@pytest.mark.django_db
def test_filter_events_ical_all_day(make_organization, make_user_for_organization, make_schedule, get_ical):
calendar = get_ical("calendar_with_all_day_event.ics")
organization = make_organization()
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
schedule.cached_ical_file_primary = calendar.to_ical()
for u in ("@Bernard Desruisseaux", "@Bob", "@Alex"):
make_user_for_organization(organization, username=u)
day_to_check_iso = "2021-01-27T15:27:14.448059+00:00"
parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC)
start_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date()
events = schedule.final_events("UTC", start_date, days=2)
expected_events = [
# all_day, users, start
(False, ["@Bernard Desruisseaux"], datetime.datetime(2021, 1, 26, 8, 0, tzinfo=pytz.UTC)),
(True, ["@Alex"], datetime.date(2021, 1, 27)),
(False, ["@Bob"], datetime.datetime(2021, 1, 27, 8, 0, tzinfo=pytz.UTC)),
]
expected = [{"all_day": all_day, "users": users, "start": start} for all_day, users, start in expected_events]
returned = [
{"all_day": e["all_day"], "users": [u["display_name"] for u in e["users"]], "start": e["start"]} for e in events
]
assert returned == expected
@pytest.mark.django_db
def test_final_schedule_events(make_organization, make_user_for_organization, make_on_call_shift, make_schedule):
organization = make_organization()

View file

@ -52,5 +52,5 @@ def test_remove_priority_from_username():
assert parse_username_from_string("[L1] bob") == "bob"
assert parse_username_from_string(" [L1] bob ") == "bob"
assert parse_username_from_string("[L2] bob[L1]") == "bob[L1]"
assert parse_username_from_string("[L27]bob") == "[L27]bob"
assert parse_username_from_string("[L27]bob") == "bob"
assert parse_username_from_string("[[L2]] bob[[[L1]") == "[[L2]] bob[[[L1]"

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2022-08-25 08:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0002_auto_20220705_1214'),
]
operations = [
migrations.AddField(
model_name='user',
name='hide_phone_number',
field=models.BooleanField(default=False),
),
]

View file

@ -145,6 +145,7 @@ class User(models.Model):
unverified_phone_number = models.CharField(max_length=20, null=True, default=None)
_verified_phone_number = models.CharField(max_length=20, null=True, default=None)
hide_phone_number = models.BooleanField(default=False)
slack_user_identity = models.ForeignKey(
"slack.SlackUserIdentity", on_delete=models.PROTECT, null=True, default=None, related_name="users"

View file

@ -1,8 +1,8 @@
import logging
from celery.utils.log import get_task_logger
from django.conf import settings
from django.utils import timezone
from rest_framework import status
from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient
from apps.user_management.models import Organization, Team, User
@ -15,13 +15,6 @@ def sync_organization(organization):
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
api_users, call_status = client.get_users()
status_code = call_status["status_code"]
# if stack is 404ing, delete the organization in case gcom stack is deleted.
if status_code == status.HTTP_404_NOT_FOUND:
is_deleted = delete_organization_if_needed(organization)
if is_deleted:
return
sync_instance_info(organization)
@ -82,19 +75,31 @@ def sync_users_and_teams(client, api_users, organization):
def delete_organization_if_needed(organization):
# Organization has a manually set API token, it will not be found within GCOM
# and would need to be deleted manually.
if organization.gcom_token is None:
return False
gcom_client = GcomAPIClient(organization.gcom_token)
is_stack_deleted = gcom_client.is_stack_deleted(organization.stack_id)
# Use common token as organization.gcom_token could be already revoked
client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN)
is_stack_deleted = client.is_stack_deleted(organization.stack_id)
if not is_stack_deleted:
return False
logger.info(
f"Deleting organization due to stack deletion. "
f"pk: {organization.pk}, stack_id: {organization.stack_id}, org_id: {organization.org_id}"
)
organization.delete()
return True
def cleanup_organization(organization_pk):
logger.info(f"Start cleanup Organization {organization_pk}")
try:
organization = Organization.objects.get(pk=organization_pk)
if delete_organization_if_needed(organization):
logger.info(
f"Deleting organization due to stack deletion. "
f"pk: {organization_pk}, stack_id: {organization.stack_id}, org_id: {organization.org_id}"
)
else:
logger.info(f"Organization {organization_pk} not deleted in gcom, no action taken")
except Organization.DoesNotExist:
logger.info(f"Organization {organization_pk} was not found")

View file

@ -5,7 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient
from apps.user_management.models import Team, User
from apps.user_management.sync import sync_organization
from apps.user_management.sync import cleanup_organization, sync_organization
@pytest.mark.django_db
@ -187,12 +187,11 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization):
@pytest.mark.django_db
def test_sync_organization_deleted(make_organization):
def test_cleanup_organization_deleted(make_organization):
organization = make_organization(gcom_token="TEST_GCOM_TOKEN")
with patch.object(GrafanaAPIClient, "get_users", return_value=(None, {"status_code": 404})):
with patch.object(GcomAPIClient, "get_instance_info", return_value=({"status": "deleted"}, None)):
sync_organization(organization)
with patch.object(GcomAPIClient, "get_instance_info", return_value=({"status": "deleted"}, None)):
cleanup_organization(organization.id)
with pytest.raises(ObjectDoesNotExist):
organization.refresh_from_db()

View file

@ -1,19 +1,33 @@
from rest_framework.pagination import CursorPagination, PageNumberPagination
from common.api_helpers.utils import create_engine_url
class HundredPageSizePaginator(PageNumberPagination):
class PathPrefixedPagination(PageNumberPagination):
def paginate_queryset(self, queryset, request, view=None):
request.build_absolute_uri = lambda: create_engine_url(request.get_full_path())
return super().paginate_queryset(queryset, request, view)
class PathPrefixedCursorPagination(CursorPagination):
def paginate_queryset(self, queryset, request, view=None):
request.build_absolute_uri = lambda: create_engine_url(request.get_full_path())
return super().paginate_queryset(queryset, request, view)
class HundredPageSizePaginator(PathPrefixedPagination):
page_size = 100
class FiftyPageSizePaginator(PageNumberPagination):
class FiftyPageSizePaginator(PathPrefixedPagination):
page_size = 50
class TwentyFivePageSizePaginator(PageNumberPagination):
class TwentyFivePageSizePaginator(PathPrefixedPagination):
page_size = 25
class TwentyFiveCursorPaginator(CursorPagination):
class TwentyFiveCursorPaginator(PathPrefixedCursorPagination):
page_size = 25
max_page_size = 100
page_size_query_param = "perpage"

View file

@ -336,6 +336,11 @@ CELERY_BEAT_SCHEDULE = {
"schedule": crontab(minute="*/30"),
"args": (),
},
"start_cleanup_deleted_organizations": {
"task": "apps.grafana_plugin.tasks.sync.start_cleanup_deleted_organizations",
"schedule": crontab(hour="*", minute=15),
"args": (),
},
"process_failed_to_invoke_celery_tasks": {
"task": "apps.base.tasks.process_failed_to_invoke_celery_tasks",
"schedule": 60 * 10,

View file

@ -9,6 +9,8 @@ if DB_BACKEND == "mysql": # noqa
pymysql.install_as_MySQLdb()
DEBUG = True
DATABASES = {
"default": {
"ENGINE": "django.db.backends.{}".format(DB_BACKEND), # noqa

View file

@ -142,6 +142,8 @@ CELERY_TASK_ROUTES = {
"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"},
# SLACK

View file

@ -9,15 +9,17 @@ module.exports = {
'^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils',
},
rules: {
'no-unused-vars': ['warn', { vars: 'all', args: 'after-used', ignoreRestSiblings: false }],
'react/prop-types': 'warn',
'react/display-name': 'warn',
'react/jsx-key': 'warn',
'react-hooks/exhaustive-deps': 'off',
'react/no-unescaped-entities': 'warn',
'react/jsx-no-target-blank': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'no-restricted-imports': 'warn',
eqeqeq: 'warn',
'no-duplicate-imports': 'warn',
'no-duplicate-imports': 'error',
'rulesdir/no-relative-import-paths': ['error', { allowSameFolder: true }],
'import/order': [
'error',

1
grafana-plugin/.nvmrc Normal file
View file

@ -0,0 +1 @@
14.17.0

View file

@ -1,6 +1,7 @@
{
"extends": "stylelint-config-standard",
"rules": {
"block-no-empty": [true,{ "severity": "warning"}],
"selector-pseudo-class-no-unknown": [
true,
{
@ -8,4 +9,4 @@
}
]
}
}
}

View file

@ -78,18 +78,25 @@
},
"dependencies": {
"@types/query-string": "^6.3.0",
"@types/react-transition-group": "^4.4.5",
"array-move": "^4.0.0",
"change-case": "^4.1.1",
"circular-dependency-plugin": "^5.2.2",
"dayjs": "^1.11.5",
"eslint-plugin-import": "^2.25.4",
"mobx": "5.13.0",
"mobx-react": "6.1.1",
"prettier": "^2.7.1",
"rc-table": "^7.17.1",
"react-copy-to-clipboard": "^5.0.2",
"react-draggable": "^4.4.5",
"react-emoji-render": "^1.2.4",
"react-modal": "^3.15.1",
"react-responsive": "^8.1.0",
"react-router-dom": "^5.2.0",
"react-sortable-hoc": "^1.11.0",
"react-string-replace": "^0.4.4",
"react-transition-group": "^4.4.5",
"sass-loader": "^13.0.2",
"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",

View file

@ -2,6 +2,14 @@ import React, { useEffect, useMemo } from 'react';
import { AppRootProps } from '@grafana/data';
import { Button, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui';
import dayjs from 'dayjs';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import weekday from 'dayjs/plugin/weekday';
import { observer, Provider } from 'mobx-react';
import 'interceptors';
@ -14,6 +22,16 @@ import { rootStore } from 'state';
import { useStore } from 'state/useStore';
import { useNavModel } from 'utils/hooks';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(isoWeek);
// dayjs().weekday(0);
import './style/vars.css';
import './style/index.css';
@ -121,9 +139,10 @@ export const Root = observer((props: AppRootProps) => {
grafanaUser: window.grafanaBootData.user,
enableLiveSettings: store.hasFeature(AppFeature.LiveSettings),
enableCloudPage: store.hasFeature(AppFeature.CloudConnection),
enableNewSchedulesPage: store.hasFeature(AppFeature.WebSchedules),
backendLicense,
}),
[meta, pathWithoutLeadingSlash, page, store.features]
[meta, pathWithoutLeadingSlash, page, store.features, backendLicense]
)
);
useEffect(() => {

View file

@ -13,13 +13,13 @@ interface AvatarProps {
const cx = cn.bind(styles);
const Avatar: FC<AvatarProps> = (props) => {
const { src, size, className } = props;
const { src, size, className, ...rest } = props;
if (!src) {
return null;
}
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} />;
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} {...rest} />;
};
export default Avatar;

View file

@ -0,0 +1,33 @@
.root {
position: fixed;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
max-height: 80%;
display: flex;
flex-direction: column;
border-image: initial;
outline: none;
padding: 15px;
background: #181b1f;
border: 1px solid #2d2e35;
box-shadow: 0 2px 4px 2px rgba(10, 10, 16, 0.1), 0 8px 16px rgba(10, 10, 16, 0.2), 0 12px 24px rgba(3, 3, 8, 0.3), 0 16px 32px rgba(3, 3, 8, 0.8);
border-radius: 2px;
}
.overlay {
position: fixed;
inset: 0;
z-index: 10;
/* background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(1px); */
}
.body-open {
overflow: hidden;
}

View file

@ -0,0 +1,50 @@
import React, { FC, PropsWithChildren } from 'react';
import cn from 'classnames/bind';
import ReactModal from 'react-modal';
ReactModal.setAppElement('#reactRoot');
import styles from './Modal.module.css';
export interface ModalProps {
title?: string | JSX.Element;
className?: string;
contentClassName?: string;
closeOnEscape?: boolean;
closeOnBackdropClick?: boolean;
onDismiss?: () => void;
width: string;
contentElement?: (props, children: React.ReactNode) => React.ReactNode;
isOpen: boolean;
}
const cx = cn.bind(styles);
const Modal: FC<PropsWithChildren<ModalProps>> = (props) => {
const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props;
return (
<ReactModal
shouldCloseOnOverlayClick={false}
style={{
overlay: {},
content: {
width,
},
}}
isOpen={isOpen}
onAfterOpen={() => {}}
onRequestClose={onDismiss}
contentLabel={title}
className={cx('root')}
overlayClassName={cx('overlay')}
bodyOpenClassName={cx('body-open')}
contentElement={contentElement}
>
{children}
</ReactModal>
);
};
export default Modal;

View file

@ -0,0 +1,7 @@
.root {
display: block;
}
.block {
width: 100%;
}

View file

@ -0,0 +1,118 @@
import React, { FC, useCallback, useState } from 'react';
import { getLocationSrv } from '@grafana/runtime';
import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import Text from 'components/Text/Text';
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
import { UserAction } from 'state/userAction';
import styles from './NewScheduleSelector.module.css';
interface NewScheduleSelectorProps {
onHide: () => void;
onCreate: (data: Schedule) => void;
onUpdate: () => void;
}
const cx = cn.bind(styles);
const NewScheduleSelector: FC<NewScheduleSelectorProps> = (props) => {
const { onHide, onCreate, onUpdate } = props;
const [showScheduleForm, setShowScheduleForm] = useState<boolean>(false);
const [type, setType] = useState<ScheduleType | undefined>();
const getCreateScheduleClickHandler = useCallback((type: ScheduleType) => {
return () => {
setType(type);
setShowScheduleForm(true);
};
}, []);
return (
<>
<Drawer scrollableContent title="Create new schedule" onClose={onHide} closeOnMaskClick>
<div className={cx('content')}>
<VerticalGroup spacing="lg">
{/*<Text type="secondary">
Manage on-call schedules using your favourite calendar app, such as Google Calendar or Microsoft Outlook. To
schedule on-call shifts create a new calendar and use events with the teammates usernames
</Text>*/}
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="calendar-alt" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Set up on-call rotation schedule
</Text>
<Text type="secondary">Configure rotations and shifts directly in Grafana On-Call</Text>
</VerticalGroup>
</HorizontalGroup>
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.API)}>
Create
</Button>
</WithPermissionControl>
</HorizontalGroup>
</Block>
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="download-alt" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Import schedule from iCal Url
</Text>
<Text type="secondary">Import rotations and shifts from your calendar app</Text>
</VerticalGroup>
</HorizontalGroup>
<Button variant="secondary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.Ical)}>
Create
</Button>
</HorizontalGroup>
</Block>
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="cog" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Create schedule by API
</Text>
<Text type="secondary">Configure rotations and upload calendar by Terraform file</Text>
</VerticalGroup>
</HorizontalGroup>
<Button variant="secondary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.Calendar)}>
Create
</Button>
</HorizontalGroup>
</Block>
</VerticalGroup>
</div>
</Drawer>
{showScheduleForm && (
<ScheduleForm
id="new"
type={type}
onUpdate={() => {
onHide();
onUpdate();
}}
onCreate={onCreate}
onHide={() => {
setType(undefined);
setShowScheduleForm(false);
}}
/>
)}
</>
);
};
export default NewScheduleSelector;

View file

@ -0,0 +1,42 @@
.root {
font-size: 12px;
line-height: 16px;
}
.root__type_link {
padding: 2px 4px;
background: rgba(27, 133, 94, 0.15);
border: 1px solid var(--success-text-color);
border-radius: 2px;
}
.root__type_warning {
padding: 2px 4px;
background: rgba(245, 183, 61, 0.18);
border: 1px solid var(--warning-text-color);
border-radius: 2px;
}
.icon__type_link {
color: var(--success-text-color);
}
.icon__type_warning {
color: var(--warning-text-color);
}
.tooltip {
width: auto;
}
/*
.tooltip__type_link {
border: 1px solid #6CCF8E;
background: #132322;
}
.tooltip__type_warning {
border: 1px solid #F8D06B;
background: #3A301E;
}
*/

View file

@ -0,0 +1,66 @@
import React, { FC, useCallback } from 'react';
import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip, IconName } from '@grafana/ui';
import cn from 'classnames/bind';
import Text, { TextType } from 'components/Text/Text';
import styles from './ScheduleCounter.module.css';
interface ScheduleCounterProps {
type: Partial<TextType>;
count: number;
tooltipTitle: string;
tooltipContent: React.ReactNode;
onHover: () => void;
}
const typeToIcon = {
link: 'link',
warning: 'exclamation-triangle',
};
const typeToColor = {
link: 'success',
warning: 'warning',
};
const typeToBorderColor = {
link: '#6CCF8E',
warning: '#F8D06B',
};
const typeToBackgroundColor = {
link: '#132322',
warning: '#3A301E',
};
const cx = cn.bind(styles);
const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
const { type, count, tooltipTitle, tooltipContent, onHover } = props;
return (
<Tooltip
placement="bottom-start"
interactive
content={
<div className={cx('tooltip', { [`tooltip__type_${type}`]: true })}>
<VerticalGroup>
<Text type={typeToColor[type]}>{tooltipTitle}</Text>
<Text type="secondary">{tooltipContent}</Text>
</VerticalGroup>
</div>
}
>
<div className={cx('root', { [`root__type_${type}`]: true })} onMouseEnter={onHover}>
<HorizontalGroup spacing="xs">
<Icon className={cx('icon', { [`icon__type_${type}`]: true })} name={typeToIcon[type] as IconName} />
<Text type={typeToColor[type] as TextType}>{count}</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
export default ScheduleCounter;

View file

@ -0,0 +1,46 @@
.root {
padding: 4px 10px;
gap: 10px;
background: var(--primary-background);
border: var(--border-medium);
border-radius: 2px;
}
.details {
width: auto;
padding: 10px 0;
}
.progress {
width: 100%;
height: 16px;
background-color: var(--secondary-background-shade);
position: relative;
}
.progress-filler {
height: 100%;
position: absolute;
}
.progress-filler__type_success {
background-color: var(--success-text-color);
}
.progress-filler__type_warning {
background-color: var(--warning-text-color);
}
.quality-text {
float: right;
line-height: 16px;
margin-right: 3px;
}
.quality-text__type_success {
color: var(--primary-text-color);
}
.quality-text__type_warning {
color: #111217;
}

View file

@ -0,0 +1,96 @@
import React, { FC, useCallback, useState } from 'react';
import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import Text from 'components/Text/Text';
import styles from './ScheduleQuality.module.css';
interface ScheduleQualityProps {
quality: number;
}
const cx = cn.bind(styles);
const ScheduleQuality: FC<ScheduleQualityProps> = (props) => {
const { quality } = props;
return (
<Tooltip placement="bottom-end" interactive content={<SheduleQualityDetails quality={quality} />}>
<div className={cx('root')}>
<HorizontalGroup spacing="sm">
<Text type="secondary">Quality:</Text>
<Text type="primary">{Math.floor(quality * 100)}%</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
interface ScheduleQualityDetailsProps {
quality: number;
}
const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => {
const { quality } = props;
const [expanded, setExpanded] = useState<boolean>(false);
const type = quality > 0.8 ? 'success' : 'warning';
const qualityPercent = quality * 100;
const handleExpandClick = useCallback(() => {
setExpanded((expanded) => !expanded);
}, []);
return (
<div className={cx('details')}>
<VerticalGroup>
<Text type="secondary">Schedule quality</Text>
<div className={cx('progress')}>
<div
style={{ width: `${qualityPercent}%` }}
className={cx('progress-filler', {
[`progress-filler__type_${type}`]: true,
})}
>
<div
className={cx('quality-text', {
[`quality-text__type_${type}`]: true,
})}
>
{qualityPercent}%
</div>{' '}
</div>
</div>
{type === 'success' && (
<Text type="primary">
You are doing a great job! <br />
Schedule is well balanced for all members.
</Text>
)}
{type === 'warning' && <Text type="primary">Your schedule has balance problems.</Text>}
<hr style={{ width: '100%' }} />
<VerticalGroup>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm">
<Icon name="info-circle" />
<Text type="secondary">Calculation methodology</Text>
</HorizontalGroup>
<IconButton name="angle-down" onClick={handleExpandClick} />
</HorizontalGroup>
{expanded && (
<Text type="secondary">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies.
Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod.
</Text>
)}
</VerticalGroup>
</VerticalGroup>
</div>
);
};
export default ScheduleQuality;

View file

@ -0,0 +1,38 @@
.root {
width: 220px;
padding: 10px;
}
.oncall-badge {
line-height: 16px;
color: var(--primary-background);
padding: 2px 7px;
border-radius: 4px;
margin-bottom: 10px;
}
.oncall-badge__type_now {
background: #6ccf8e;
}
.oncall-badge__type_inside {
background: #ccccdc;
}
.oncall-badge__type_outside {
background: rgba(204, 204, 220, 0.4);
}
.hr {
width: 100%;
margin: 0 -11px;
}
.times {
display: flex;
flex-direction: column;
}
.icon {
color: #ccccdc;
}

View file

@ -0,0 +1,121 @@
import React, { FC } from 'react';
import { Icon, Button, HorizontalGroup, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import Line from './img/line.svg';
import styles from './ScheduleUserDetails.module.css';
interface ScheduleUserDetailsProps {
currentMoment: dayjs.Dayjs;
user: User;
}
const cx = cn.bind(styles);
enum UserOncallStatus {
Now = 'now',
Outside = 'outside',
Inside = 'inside',
}
const userOncallStatusToText = {
[UserOncallStatus.Now]: 'Oncall now',
[UserOncallStatus.Inside]: 'Inside working hours',
[UserOncallStatus.Outside]: 'Outside working hours',
};
const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
const { user, currentMoment } = props;
const userStatus =
Math.random() > 0.66
? UserOncallStatus.Now
: Math.random() > 0.33
? UserOncallStatus.Inside
: UserOncallStatus.Outside;
const userMoment = currentMoment.tz(user.timezone);
const userOffsetHoursStr = getTzOffsetString(userMoment);
return (
<div className={cx('root')}>
<VerticalGroup spacing="sm">
<HorizontalGroup justify="space-between">
<Avatar src={user.avatar} size="large" />
{/*<Button variant="secondary">
<HorizontalGroup spacing="sm">
<Icon name="bell" />
Push
</HorizontalGroup>
</Button>*/}
</HorizontalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">{user.username}</Text>
<Text type="secondary">
{`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr}
</Text>
{/* <div
className={cx('oncall-badge', {
[`oncall-badge__type_${userStatus}`]: true,
})}
>
{userOncallStatusToText[userStatus]}
</div>
<HorizontalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">Next shift</Text>
<div className={cx('times')}>
<HorizontalGroup>
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">30 apr, 00:00</Text>
<Text type="secondary">30 apr, 23:59</Text>
</VerticalGroup>
</HorizontalGroup>
</div>
</VerticalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">Last shift</Text>
<div className={cx('times')}>
<HorizontalGroup>
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">30 apr, 00:00</Text>
<Text type="secondary">30 apr, 23:59</Text>
</VerticalGroup>
</HorizontalGroup>
</div>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
<hr style={{ width: '100%' }} />
<VerticalGroup spacing="sm">
<Text type="primary">Contacts</Text>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="message" />
<Text type="link">mail@grafana.com</Text>
</HorizontalGroup>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="slack" />
<Text type="link">@slackid</Text>
</HorizontalGroup>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="phone" />
<Text type="secondary">+39 555 449 00 00</Text>
</HorizontalGroup>*/}
</VerticalGroup>
</VerticalGroup>
</div>
);
};
export default ScheduleUserDetails;

View file

@ -0,0 +1,5 @@
<svg width="14" height="34" viewBox="0 0 14 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.2207 13.436C6.93327 13.436 7.60465 13.2982 8.23486 13.0225C8.86507 12.7503 9.42008 12.3743 9.8999 11.8945C10.3797 11.4147 10.7557 10.8615 11.0278 10.2349C11.3 9.60465 11.436 8.93148 11.436 8.21533C11.436 7.50277 11.2982 6.83138 11.0225 6.20117C10.7503 5.57096 10.3743 5.01595 9.89453 4.53613C9.41471 4.05632 8.8597 3.68034 8.22949 3.4082C7.59928 3.13607 6.9279 3 6.21533 3C5.50277 3 4.83138 3.13607 4.20117 3.4082C3.57454 3.68034 3.01953 4.05632 2.53613 4.53613C2.05632 5.01595 1.68034 5.57096 1.4082 6.20117C1.13607 6.83138 1 7.50277 1 8.21533C1 8.93148 1.13607 9.60465 1.4082 10.2349C1.68392 10.8615 2.06169 11.4147 2.5415 11.8945C3.02132 12.3743 3.57454 12.7503 4.20117 13.0225C4.83138 13.2982 5.50456 13.436 6.2207 13.436ZM6.2207 12.48C5.62988 12.48 5.07666 12.369 4.56104 12.147C4.04541 11.9285 3.59245 11.6242 3.20215 11.2339C2.81185 10.8436 2.50749 10.3906 2.28906 9.875C2.07064 9.35938 1.96143 8.80615 1.96143 8.21533C1.96143 7.62451 2.07064 7.07129 2.28906 6.55566C2.50749 6.04004 2.81185 5.58708 3.20215 5.19678C3.59245 4.80648 4.04362 4.50212 4.55566 4.28369C5.07129 4.06527 5.62451 3.95605 6.21533 3.95605C6.80615 3.95605 7.35938 4.06527 7.875 4.28369C8.39062 4.50212 8.84359 4.80648 9.23389 5.19678C9.62419 5.58708 9.92855 6.04004 10.147 6.55566C10.369 7.07129 10.48 7.62451 10.48 8.21533C10.4836 8.80615 10.3743 9.35938 10.1523 9.875C9.93392 10.3906 9.62956 10.8436 9.23926 11.2339C8.85254 11.6242 8.39958 11.9285 7.88037 12.147C7.36475 12.369 6.81152 12.48 6.2207 12.48ZM4.98535 9.39697C5.28255 9.69417 5.59229 9.95736 5.91455 10.1865C6.2404 10.4121 6.56266 10.584 6.88135 10.7021C7.20361 10.8167 7.50798 10.8579 7.79443 10.8257C8.08089 10.7935 8.33512 10.6663 8.55713 10.4443C8.57503 10.43 8.59115 10.4139 8.60547 10.396C8.61979 10.3781 8.63411 10.3602 8.64844 10.3423C8.75944 10.2134 8.8221 10.0719 8.83643 9.91797C8.85433 9.764 8.78988 9.63151 8.64307 9.52051C8.56071 9.46322 8.47656 9.40413 8.39062 9.34326C8.30827 9.28239 8.21338 9.21615 8.10596 9.14453C8.00212 9.06934 7.88216 8.98519 7.74609 8.89209C7.5957 8.78825 7.4668 8.74528 7.35938 8.76318C7.25195 8.78109 7.14095 8.84554 7.02637 8.95654L6.82764 9.1499C6.79541 9.18213 6.75781 9.19824 6.71484 9.19824C6.67188 9.19466 6.63607 9.18213 6.60742 9.16064C6.5179 9.10693 6.40332 9.02637 6.26367 8.91895C6.1276 8.80794 5.98796 8.68083 5.84473 8.5376C5.70866 8.39795 5.58333 8.2583 5.46875 8.11865C5.35417 7.979 5.27181 7.86621 5.22168 7.78027C5.20378 7.75163 5.19124 7.71761 5.18408 7.67822C5.1805 7.63883 5.19661 7.60124 5.23242 7.56543L5.43115 7.35596C5.54215 7.23421 5.60661 7.12142 5.62451 7.01758C5.646 6.91374 5.60124 6.78841 5.49023 6.6416L4.86719 5.76074C4.79915 5.66048 4.72038 5.59782 4.63086 5.57275C4.54134 5.54411 4.44287 5.54948 4.33545 5.58887C4.23161 5.62467 4.12419 5.68376 4.01318 5.76611C3.99886 5.77327 3.98633 5.78223 3.97559 5.79297C3.96484 5.80371 3.9541 5.81445 3.94336 5.8252C3.72135 6.0472 3.59424 6.30322 3.56201 6.59326C3.52979 6.87972 3.57096 7.18408 3.68555 7.50635C3.80371 7.82503 3.97559 8.1473 4.20117 8.47314C4.42676 8.79541 4.68815 9.10335 4.98535 9.39697Z" fill="#CCCCDC"/>
<path d="M6.0332 13.0295L6.0332 24.543" stroke="#CCCCDC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 31.0005C7.65685 31.0005 9 29.6573 9 28.0005C9 26.3436 7.65685 25.0005 6 25.0005C4.34315 25.0005 3 26.3436 3 28.0005C3 29.6573 4.34315 31.0005 6 31.0005ZM6 32.0005C8.20914 32.0005 10 30.2096 10 28.0005C10 25.7913 8.20914 24.0005 6 24.0005C3.79086 24.0005 2 25.7913 2 28.0005C2 30.2096 3.79086 32.0005 6 32.0005Z" fill="#CCCCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,25 @@
import moment from 'moment';
export function optionToDateString(option: string) {
switch (option) {
case 'today':
return moment().startOf('day').format('YYYY-MM-DD');
case 'tomorrow':
return moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
default:
return moment().add(2, 'day').startOf('day').format('YYYY-MM-DD');
}
}
export function dateStringToOption(dateString: string) {
const today = moment().startOf('day').format('YYYY-MM-DD');
if (dateString === today) {
return 'today';
}
const tomorrow = moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
if (dateString === tomorrow) {
return 'tomorrow';
}
return 'custom';
}

View file

@ -0,0 +1,4 @@
.root {
display: inline-flex;
align-items: center;
}

View file

@ -0,0 +1,97 @@
import React, { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { DatePickerWithInput, Field, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { ScheduleType } from 'models/schedule/schedule.types';
import { dateStringToOption, optionToDateString } from './SchedulesFilters.helpers';
import { SchedulesFiltersType } from './SchedulesFilters.types';
import styles from './SchedulesFilters.module.css';
const cx = cn.bind(styles);
interface SchedulesFiltersProps {
value: SchedulesFiltersType;
onChange: (filters: SchedulesFiltersType) => void;
}
const SchedulesFilters = (props: SchedulesFiltersProps) => {
const { value, onChange } = props;
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange({ ...value, searchTerm: e.currentTarget.value });
},
[value]
);
const handleStatusChange = useCallback(
(status) => {
onChange({ ...value, status });
},
[value]
);
const handleTypeChange = useCallback(
(type) => {
onChange({ ...value, type });
},
[value]
);
return (
<div className={cx('root')}>
<HorizontalGroup spacing="lg">
<Field label="Search by name, user or object ID">
<Input
autoFocus
className={cx('search')}
prefix={<Icon name="search" />}
placeholder="Search..."
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
/>
</Field>
<Field label="Status">
<RadioButtonGroup
options={[
{ label: 'All', value: 'all' },
{
label: 'Used in escalations',
value: 'used',
},
{ label: 'Unused', value: 'unused' },
]}
value={value.status}
onChange={handleStatusChange}
/>
</Field>
<Field label="Type">
<RadioButtonGroup
disabled
options={[
{ label: 'All', value: 'all' },
{
label: 'Web',
value: ScheduleType.API,
},
{
label: 'ICal',
value: ScheduleType.Ical,
},
{
label: 'API',
value: ScheduleType.Calendar,
},
]}
value={value.type}
onChange={handleTypeChange}
/>
</Field>
</HorizontalGroup>
</div>
);
};
export default SchedulesFilters;

View file

@ -0,0 +1,7 @@
import { ScheduleType } from 'models/schedule/schedule.types';
export interface SchedulesFiltersType {
searchTerm: string;
type: ScheduleType;
status: string;
}

View file

@ -1,29 +1,29 @@
.root {
position: relative;
width: 100%;
&:hover .copyButton {
opacity: 1;
}
}
.scroller {
overflow-y: auto;
border-radius: 2px;
padding: 12px 60px 12px 20px;
&--maxHeight {
max-height: 400px;
}
}
.scroller_max-height {
max-height: 400px;
}
.root .button {
.copyIcon,
.copyButton {
position: absolute;
top: 15px;
right: 15px;
opacity: 0;
transition: opacity 0.2s ease;
}
.root:hover .button {
opacity: 1;
}
.root pre {
border-radius: 2px;
padding: 12px 20px;
}
.copyButton {
opacity: 0;
}

View file

@ -1,41 +1,47 @@
import React, { FC } from 'react';
import { Button } from '@grafana/ui';
import { Button, Icon, IconButton } from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import { openNotification } from 'utils';
import styles from './SourceCode.module.css';
import styles from './SourceCode.module.scss';
const cx = cn.bind(styles);
interface SourceCodeProps {
noMaxHeight?: boolean;
showClipboardIconOnly?: boolean;
showCopyToClipboard?: boolean;
children?: any
children?: any;
}
const SourceCode: FC<SourceCodeProps> = (props) => {
const { children, noMaxHeight = false, showCopyToClipboard = true } = props;
const { children, noMaxHeight = false, showClipboardIconOnly = false, showCopyToClipboard = true } = props;
const showClipboardCopy = showClipboardIconOnly || showCopyToClipboard;
return (
<div className={cx('root')}>
{showCopyToClipboard && (
{showClipboardCopy && (
<CopyToClipboard
text={children as string}
onCopy={() => {
openNotification('Copied!');
}}
>
<Button className={cx('button')} variant="primary" icon="copy">
Copy
</Button>
{showClipboardIconOnly ? (
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" />
) : (
<Button className={cx('copyButton')} variant="primary" size="xs" icon="copy">
Copy
</Button>
)}
</CopyToClipboard>
)}
<pre
className={cx('scroller', {
'scroller_max-height': !noMaxHeight,
'scroller--maxHeight': !noMaxHeight,
})}
>
<code>{children}</code>

View file

@ -0,0 +1,44 @@
.root {
width: 100%;
}
.root table {
width: 100%;
background: #22252b;
}
.root tr {
border-bottom: 1px solid #181b1f;
height: 60px;
}
.root tr:hover {
/* background: var(--secondary-background); */
background: rgba(63, 62, 62, 0.45);
}
.root th:first-child {
padding-left: 20px;
}
.root td {
min-height: 60px;
padding: 10px 0;
}
.pagination {
width: 100%;
margin-top: 20px;
}
.expand-icon {
padding: 10px;
pointer-events: none;
transform: rotate(-90deg);
transform-origin: center;
transition: transform 0.2s;
}
.expand-icon__expanded {
transform: rotate(0deg);
}

View file

@ -0,0 +1,69 @@
import React, { FC, useState, useCallback, useMemo, ChangeEvent } from 'react';
import { Pagination, Checkbox, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Table from 'rc-table';
import { TableProps } from 'rc-table/lib/Table';
import { ExpandIcon } from 'icons';
import styles from './Table.module.css';
const cx = cn.bind(styles);
export interface Props<RecordType = unknown> extends TableProps<RecordType> {
loading?: boolean;
pagination?: {
page: number;
total: number;
onChange: (page: number) => void;
};
rowSelection?: {
selectedRowKeys: string[];
onChange: (selectedRowKeys: string[]) => void;
};
expandable?: {
expandedRowKeys: string[];
expandedRowRender: (item: any) => React.ReactNode;
onExpandedRowsChange?: (rows: string[]) => void;
expandRowByClick: boolean;
expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode;
onExpand?: (expanded: boolean, item: any) => void;
};
}
const GTable: FC<Props> = (props) => {
const { columns, data, className, pagination, loading, rowKey, expandable, ...restProps } = props;
const { page, total: numberOfPages, onChange: onNavigate } = pagination || {};
if (expandable) {
expandable.expandIcon = ({ expanded, record }) => {
return (
<div className={cx('expand-icon', { [`expand-icon__expanded`]: expanded })}>
<ExpandIcon />
</div>
);
};
}
return (
<VerticalGroup justify="flex-end">
<Table
rowKey={rowKey}
className={cx('root', className)}
columns={columns}
data={data}
expandable={expandable}
{...restProps}
/>
{pagination && (
<div className={cx('pagination')}>
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={numberOfPages} onNavigate={onNavigate} />
</div>
)}
</VerticalGroup>
);
};
export default GTable;

View file

@ -40,6 +40,7 @@
white-space: nowrap;
}
.keyboard {
margin: 0 0.2em;
padding: 0.15em 0.4em 0.1em;
@ -56,4 +57,9 @@
.icon-button {
margin-left: 4px;
display: none;
}
.root:hover .icon-button {
display: inline-block;
}

View file

@ -8,8 +8,10 @@ import { openNotification } from 'utils';
import styles from './Text.module.scss';
export type TextType = 'primary' | 'secondary' | 'disabled' | 'link' | 'success' | 'warning';
interface TextProps extends HTMLAttributes<HTMLElement> {
type?: 'primary' | 'secondary' | 'disabled' | 'link' | 'success' | 'warning';
type?: TextType;
strong?: boolean;
underline?: boolean;
size?: 'small' | 'medium' | 'large';
@ -21,9 +23,10 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
onTextChange?: (value: string) => void;
clearBeforeEdit?: boolean;
hidden?: boolean;
editModalTitle?: string;
}
interface TextType extends React.FC<TextProps> {
interface TextInterface extends React.FC<TextProps> {
Title: React.FC<TitleProps>;
}
@ -31,7 +34,7 @@ const PLACEHOLDER = '**********';
const cx = cn.bind(styles);
const Text: TextType = (props) => {
const Text: TextInterface = (props) => {
const {
type,
size = 'medium',
@ -47,6 +50,7 @@ const Text: TextType = (props) => {
onTextChange,
clearBeforeEdit = false,
hidden = false,
editModalTitle = 'New value',
} = props;
const [isEditMode, setIsEditMode] = useState<boolean>(false);
@ -81,7 +85,7 @@ const Text: TextType = (props) => {
'text--strong': strong,
'text--underline': underline,
'no-wrap': !wrap,
keyboard
keyboard,
})}
>
{hidden ? PLACEHOLDER : children}
@ -112,7 +116,7 @@ const Text: TextType = (props) => {
</CopyToClipboard>
)}
{isEditMode && (
<Modal onDismiss={handleCancelEdit} closeOnEscape isOpen title="New value">
<Modal onDismiss={handleCancelEdit} closeOnEscape isOpen title={editModalTitle}>
<VerticalGroup>
<Input
autoFocus

View file

@ -0,0 +1,62 @@
.root {
position: absolute;
display: flex;
z-index: 1;
width: 100%;
top: 0;
bottom: 0;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: rgba(204, 204, 220, 0.65);
pointer-events: none;
}
.weekday {
width: calc(100% / 7);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.weekday-title {
width: 100%;
text-align: center;
padding-top: 4px;
flex-grow: 1;
}
.weekday:not(:last-child) .weekday-title {
border-right: var(--border-medium);
}
.weekday-times {
width: 100%;
display: flex;
height: 20px;
align-items: center;
}
.weekday-time {
width: 50%;
}
.weekday-time-title {
display: inline-block;
transform: translate(-50%, 0);
}
.weekday-time-title__hidden {
visibility: hidden;
}
/*
for debug purposes only
*/
.debug-scale {
position: absolute;
top: -6px;
width: 100%;
right: 0;
}

View file

@ -0,0 +1,84 @@
import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import styles from './TimelineMarks.module.css';
interface TimelineMarksProps {
startMoment: dayjs.Dayjs;
debug?: boolean;
}
const cx = cn.bind(styles);
const TimelineMarks: FC<TimelineMarksProps> = (props) => {
const { startMoment, debug } = props;
const momentsToRender = useMemo(() => {
const hoursToSplit = 12;
const momentsToRender = [];
const jLimit = 24 / hoursToSplit;
for (let i = 0; i < 7; i++) {
const d = dayjs(startMoment).add(i, 'days');
const obj = { moment: d, moments: [] };
for (let j = 0; j < jLimit; j++) {
const m = dayjs(d).add(j * hoursToSplit, 'hour');
obj.moments.push(m);
}
momentsToRender.push(obj);
}
return momentsToRender;
}, [startMoment]);
const cuts = useMemo(() => {
const cuts = [];
for (let i = 0; i <= 24 * 7; i++) {
cuts.push({});
}
return cuts;
}, []);
return (
<div className={cx('root')}>
{debug && (
<svg version="1.1" width="100%" height="6px" xmlns="http://www.w3.org/2000/svg" className={cx('debug-scale')}>
{cuts.map((cut, index) => (
<line
x1={`${(index * 100) / (24 * 7)}%`}
strokeWidth={1}
y1="0"
x2={`${(index * 100) / (24 * 7)}%`}
y2="6px"
stroke="rgba(204, 204, 220, 0.65)"
/>
))}
</svg>
)}
{momentsToRender.map((m, i) => {
return (
<div key={i} className={cx('weekday')}>
<div className={cx('weekday-title')}>{m.moment.format('ddd D MMM')}</div>
<div className={cx('weekday-times')}>
{m.moments.map((mm, j) => (
<div key={j} className={cx('weekday-time')}>
<div
className={cx('weekday-time-title', {
'weekday-time-title__hidden': i === 0 && j === 0,
})}
>
{mm.format('HH:mm')}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
export default TimelineMarks;

View file

@ -0,0 +1,41 @@
import { Item } from './UserGroups.types';
export const toPlainArray = (groups: string[][]) => {
const items: Item[] = [];
groups.forEach((group: string[], groupIndex: number) => {
items.push({
key: `group-${groupIndex}`,
type: 'group',
data: { name: `Group ${groupIndex + 1}` },
});
groups[groupIndex].forEach((item: string, itemIndex: number) => {
items.push({
key: `item-${groupIndex}-${itemIndex}`,
type: 'item',
data: item,
});
});
});
return items;
};
export const fromPlainArray = (items: Item[], createNewGroup = false, deleteEmptyGroups = true) => {
return items
.reduce((memo: any, item: Item, currentIndex: number) => {
if (item.type === 'item') {
let lastGroup = memo[memo.length - 1];
if (!lastGroup || (createNewGroup && currentIndex === items.length - 1)) {
lastGroup = [];
memo.push(lastGroup);
}
lastGroup.push(item.data);
} else {
memo.push([]);
}
return memo;
}, [])
.filter((group: string[][]) => !deleteEmptyGroups || group.length);
};

View file

@ -0,0 +1,93 @@
.root {
width: 100%;
}
.sortable-helper {
z-index: 1062;
box-shadow: var(--focused-box-shadow);
background: var(--hover-selected-hardcoded) !important;
}
.separator {
font-weight: 400;
font-size: 12px;
line-height: 16px;
text-align: center;
color: rgba(204, 204, 220, 0.4);
margin: 4px 0;
display: flex;
align-items: center;
}
.separator__clickable {
cursor: pointer;
}
.separator::before {
display: block;
content: "";
flex-grow: 1;
border-bottom: 1px solid rgba(204, 204, 220, 0.15);
height: 0;
margin-right: 5px;
}
.separator::after {
display: block;
content: "";
flex-grow: 1;
border-bottom: 1px solid rgba(204, 204, 220, 0.15);
height: 0;
margin-left: 5px;
}
.groups {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 1px;
}
.user {
background: #22252b;
border-radius: 2px;
display: flex;
position: relative;
overflow: hidden;
}
.user-buttons {
position: absolute;
top: 8px;
right: 5px;
}
.user:hover {
background: var(--hover-selected-hardcoded);
}
.delete-icon {
/* display: none; */
display: block;
}
.user:hover .delete-icon {
display: block;
}
.add-user-group {
width: 100%;
text-align: center;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: var(--secondary-text-color);
cursor: pointer;
}
.select {
width: 100%;
}

View file

@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { VerticalGroup, HorizontalGroup, IconButton, Field, Input } from '@grafana/ui';
import { arrayMoveImmutable } from 'array-move';
import cn from 'classnames/bind';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import { User } from 'models/user/user.types';
import { fromPlainArray, toPlainArray } from './UserGroups.helpers';
import { Item } from './UserGroups.types';
import styles from './UserGroups.module.css';
interface UserGroupsProps {
value: Array<Array<User['pk']>>;
onChange: (value: Array<Array<User['pk']>>) => void;
isMultipleGroups: boolean;
renderUser: (id: string) => React.ReactElement;
showError?: boolean;
}
const cx = cn.bind(styles);
const DragHandle = () => <IconButton name="draggabledots" />;
const SortableHandleHoc = SortableHandle(DragHandle);
const UserGroups = (props: UserGroupsProps) => {
const { value, onChange, isMultipleGroups, renderUser, showError } = props;
const handleAddUserGroup = useCallback(() => {
onChange([...value, []]);
}, [value]);
const handleDeleteUser = (index: number) => {
const newGroups = [...value];
let k = -1;
for (let i = 0; i < value.length; i++) {
k++;
const users = value[i];
for (let j = 0; j < users.length; j++) {
k++;
if (k === index) {
newGroups[i] = newGroups[i].filter((item, itemIndex) => itemIndex !== j);
onChange(newGroups.filter((group) => group.length));
return;
}
}
}
};
const handleUserAdd = useCallback(
(pk: User['pk']) => {
if (!pk) {
return;
}
const newGroups = [...value];
let lastGroup = newGroups[newGroups.length - 1];
if (!lastGroup) {
lastGroup = [];
newGroups.push(lastGroup);
}
lastGroup.push(pk);
onChange(newGroups);
},
[value]
);
const items = useMemo(() => toPlainArray(value), [value]);
const onSortEnd = useCallback(
({ oldIndex, newIndex }) => {
const newPlainArray = arrayMoveImmutable(items, oldIndex, newIndex);
onChange(fromPlainArray(newPlainArray, newIndex > items.length));
},
[items]
);
const getDeleteItemHandler = (index: number) => {
return () => {
handleDeleteUser(index);
};
};
const renderItem = (item: Item, index: number) => (
<li className={cx('user')}>
{renderUser(item.data)}
<div className={cx('user-buttons')}>
<HorizontalGroup>
<IconButton className={cx('delete-icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<SortableHandleHoc />
</HorizontalGroup>
</div>
</li>
);
return (
<div className={cx('root')}>
<VerticalGroup>
<RemoteSelect
key={items.length}
showSearch
placeholder="Add user"
href="/users/?filters=true&roles=0&roles=1"
value={null}
onChange={handleUserAdd}
showError={showError}
maxMenuHeight={150}
/>
<SortableList
renderItem={renderItem}
axis="y"
lockAxis="y"
helperClass={cx('sortable-helper')}
items={items}
onSortEnd={onSortEnd}
handleAddGroup={handleAddUserGroup}
handleDeleteItem={handleDeleteUser}
isMultipleGroups={isMultipleGroups}
useDragHandle
/>
</VerticalGroup>
</div>
);
};
interface SortableItemProps {
children: React.ReactElement;
}
const SortableItem = SortableElement<SortableItemProps>(({ children }) => children);
interface SortableListProps {
items: Item[];
handleAddGroup: () => void;
handleDeleteItem: (index: number) => void;
isMultipleGroups: boolean;
renderItem: (item: Item, index: number) => React.ReactElement;
}
const SortableList = SortableContainer<SortableListProps>(({ items, handleAddGroup, isMultipleGroups, renderItem }) => {
return (
<ul className={cx('groups')}>
{items.map((item, index) =>
item.type === 'item' ? (
<SortableItem key={item.key} index={index}>
{renderItem(item, index)}
</SortableItem>
) : isMultipleGroups ? (
<SortableItem key={item.key} index={index}>
<li className={cx('separator')}>{item.data.name}</li>
</SortableItem>
) : null
)}
{isMultipleGroups && items[items.length - 1]?.type === 'item' && (
<SortableItem disabled key="New Group" index={items.length + 1}>
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
Add user group +
</li>
</SortableItem>
)}
</ul>
);
});
export default UserGroups;

View file

@ -0,0 +1,5 @@
export interface Item {
key: string;
type: string;
data: any;
}

View file

@ -0,0 +1,3 @@
.root {
width: 300px;
}

View file

@ -0,0 +1,98 @@
import React, { FC, useCallback, useMemo } from 'react';
import { Select } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { get } from 'lodash-es';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import styles from './UserTimezoneSelect.module.css';
interface UserTimezoneSelectProps {
users: User[];
value: Timezone;
onChange: (value: Timezone) => void;
}
const cx = cn.bind(styles);
const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
const { users, value: propValue, onChange } = props;
const options = useMemo(() => {
return users
.reduce(
(memo, user) => {
const moment = dayjs().tz(user.timezone);
const utcOffset = moment.utcOffset();
let item = memo.find((item) => item.utcOffset === utcOffset);
if (!item) {
item = {
value: utcOffset,
utcOffset,
timezone: user.timezone,
label: getTzOffsetString(moment),
description: user.username,
};
memo.push(item);
} else {
item.description += item.description ? ', ' + user.username : user.username;
// item.imgUrl = undefined;
}
return memo;
},
[
{
value: 0,
utcOffset: 0,
timezone: 'UTC' as Timezone,
label: 'GMT',
description: '',
},
]
)
.sort((a, b) => {
if (b.utcOffset === 0) {
return 1;
}
if (a.utcOffset > b.utcOffset) {
return 1;
}
if (a.utcOffset < b.utcOffset) {
return -1;
}
return 0;
});
}, [users]);
const value = useMemo(() => {
const utcOffset = dayjs().tz(propValue).utcOffset();
const option = options.find((option) => option.utcOffset === utcOffset);
return option?.value;
}, [propValue, options]);
const handleChange = useCallback(
({ value }) => {
const option = options.find((option) => option.utcOffset === value);
onChange(option?.timezone);
},
[options]
);
return (
<div className={cx('root')}>
<Select value={value} onChange={handleChange} width={100} placeholder="UTC Timezone" options={options} />
</div>
);
};
export default UserTimezoneSelect;

View file

@ -0,0 +1,9 @@
export const default_working_hours = {
friday: [{ end: '17:00:00', start: '09:00:00' }],
monday: [{ end: '17:00:00', start: '09:00:00' }],
sunday: [],
tuesday: [{ end: '17:00:00', start: '09:00:00' }],
saturday: [],
thursday: [{ end: '17:00:00', start: '09:00:00' }],
wednesday: [{ end: '17:00:00', start: '09:00:00' }],
};

View file

@ -0,0 +1,84 @@
import dayjs from 'dayjs';
export const getWorkingMoments = (startMoment, endMoment, workingHours, timezone) => {
const weekdays = dayjs.weekdays();
const momentToStartIteration = dayjs().tz(timezone).utcOffset() === 0 ? startMoment : startMoment.tz(timezone);
const dayOfWeekToStartIteration = momentToStartIteration.format('dddd');
const weekDaysToIterateChunk = [
dayOfWeekToStartIteration,
...weekdays.slice(weekdays.indexOf(dayOfWeekToStartIteration) + 1),
...weekdays.slice(0, weekdays.indexOf(dayOfWeekToStartIteration)),
];
const weeks = endMoment.diff(startMoment, 'weeks');
const weekDaysToIterate = [...weekDaysToIterateChunk];
for (let i = 0; i < weeks; i++) {
weekDaysToIterate.push(...weekDaysToIterateChunk);
}
const workingMoments = [];
for (const [i, weekday] of weekDaysToIterate.entries()) {
for (const range of workingHours[weekday.toLowerCase()]) {
const rangeStartData = range.start;
const rangeEndData = range.end;
const [start_HH, start_mm, start_ss] = rangeStartData.split(':');
const [end_HH, end_mm, end_ss] = rangeEndData.split(':');
const rangeStartMoment = dayjs(momentToStartIteration)
.add(i, 'day')
.set('hour', Number(start_HH))
.set('minute', Number(start_mm))
.set('second', Number(start_ss));
const rangeEndMoment = dayjs(momentToStartIteration)
.add(i, 'day')
.set('hour', Number(end_HH))
.set('minute', Number(end_mm))
.set('second', Number(end_ss));
if (rangeEndMoment.isSameOrBefore(startMoment)) {
continue;
} else if (rangeStartMoment.isSameOrAfter(endMoment)) {
continue;
}
if (
rangeStartMoment.isSameOrBefore(startMoment) &&
rangeEndMoment.isSameOrAfter(startMoment) &&
rangeEndMoment.isSameOrBefore(endMoment)
) {
workingMoments.push({ start: startMoment, end: rangeEndMoment });
} else if (
rangeEndMoment.isSameOrAfter(endMoment) &&
rangeStartMoment.isSameOrBefore(endMoment) &&
rangeStartMoment.isSameOrAfter(startMoment)
) {
workingMoments.push({ start: rangeStartMoment, end: endMoment });
} else {
workingMoments.push({ start: rangeStartMoment, end: rangeEndMoment });
}
}
}
return workingMoments;
};
export const getNonWorkingMoments = (startMoment, endMoment, workingHours) => {
const nonWorkingMoments = [{ start: startMoment, end: endMoment }];
let lastNonWorkingRange = nonWorkingMoments[0];
for (const [i, range] of workingHours.entries()) {
lastNonWorkingRange.end = range.start;
lastNonWorkingRange = { start: range.end, end: undefined };
nonWorkingMoments.push(lastNonWorkingRange);
}
lastNonWorkingRange.end = endMoment;
return nonWorkingMoments;
};

View file

@ -0,0 +1,3 @@
.root {
display: block;
}

View file

@ -0,0 +1,97 @@
import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import { Timezone } from 'models/timezone/timezone.types';
import { default_working_hours } from './WorkingHours.config';
import { getNonWorkingMoments, getWorkingMoments } from './WorkingHours.helpers';
import styles from './WorkingHours.module.css';
import { start } from 'repl';
interface WorkingHoursProps {
timezone: Timezone;
workingHours: any;
startMoment: dayjs.Dayjs;
duration: number; // in seconds
className: string;
style?: React.CSSProperties;
}
const cx = cn.bind(styles);
const WorkingHours: FC<WorkingHoursProps> = (props) => {
const { timezone, workingHours = default_working_hours, startMoment, duration, className, style } = props;
const endMoment = startMoment.add(duration, 'seconds');
const workingMoments = useMemo(
() => getWorkingMoments(startMoment, endMoment, workingHours, timezone),
[startMoment, endMoment, workingHours, timezone]
);
/*console.log(
workingMoments.map(({ start, end }) => `${start.diff(startMoment, 'hours')} - ${end.diff(startMoment, 'hours')}`)
);*/
const nonWorkingMoments = useMemo(
() => getNonWorkingMoments(startMoment, endMoment, workingMoments),
[startMoment, endMoment, workingMoments]
);
// console.log(startMoment, startMoment.toString());
/* console.log(
workingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
); */
// console.log(workingHours);
/*console.log(
nonWorkingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
);*/
return (
<svg
version="1.1"
width="100%"
height="28px"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<defs>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
</pattern>
</defs>
{nonWorkingMoments.map((moment, index) => {
const start = moment.start.diff(startMoment, 'seconds');
const diff = moment.end.diff(moment.start, 'seconds');
return (
<rect
className={cx('stripes')}
key={index}
x={`${(start * 100) / duration}%`}
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill="url(#stripes)"
/>
);
})}
</svg>
);
};
export default WorkingHours;

View file

@ -112,3 +112,7 @@
.slack-channel-switch {
margin-left: -8px;
}
.description-style a {
color: var(--primary-text-link);
}

View file

@ -280,12 +280,14 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
</div>
</Block>
{alertReceiveChannel.description && (
<Alert
style={{ marginBottom: '0' }}
// @ts-ignore
title={<div dangerouslySetInnerHTML={{ __html: sanitize(alertReceiveChannel.description) }}></div>}
severity="info"
/>
<div className={cx('description-style')}>
<Alert
style={{ marginBottom: '0' }}
// @ts-ignore
title={<div dangerouslySetInnerHTML={{ __html: sanitize(alertReceiveChannel.description) }}></div>}
severity="info"
/>
</div>
)}
<div className={cx('alertRulesContent')}>
<div className={cx('alertRulesActions')}>

View file

@ -0,0 +1,16 @@
.token__inputContainer {
width: 100%;
display: flex;
margin-bottom: 24px;
}
.token__input {
flex-grow: 1;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.token__copyButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View file

@ -1,13 +1,20 @@
import React, { useCallback, HTMLAttributes, useState } from 'react';
import { Button, Field, HorizontalGroup, Input, Modal, VerticalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, Input, Label, Modal, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import CopyToClipboard from 'react-copy-to-clipboard';
import SourceCode from 'components/SourceCode/SourceCode';
import { ApiToken } from 'models/api_token/api_token.types';
import { useStore } from 'state/useStore';
import { openErrorNotification, openNotification } from 'utils';
import { getItem } from 'utils/localStorage';
import styles from './ApiTokenForm.module.css';
const cx = cn.bind(styles);
interface TokenCreationModalProps extends HTMLAttributes<HTMLElement> {
visible: boolean;
@ -16,7 +23,7 @@ interface TokenCreationModalProps extends HTMLAttributes<HTMLElement> {
}
const ApiTokenForm = observer((props: TokenCreationModalProps) => {
const { visible, onHide = () => {}, onUpdate = () => {} } = props;
const { onHide = () => {}, onUpdate = () => {} } = props;
const [name, setName] = useState('');
const [token, setToken] = useState('');
@ -39,30 +46,68 @@ const ApiTokenForm = observer((props: TokenCreationModalProps) => {
return (
<Modal isOpen closeOnEscape={false} title={token ? 'Your new API Token' : 'Create API Token'} onDismiss={onHide}>
<VerticalGroup>
<Input maxLength={50} onChange={handleNameChange} autoFocus placeholder="Enter token name" />
{token && (
<>
<Input value={token} disabled />
</>
)}
<HorizontalGroup>
{token && (
<CopyToClipboard
text={token}
onCopy={() => {
openNotification('Token copied');
}}
>
<Button>Copy Token</Button>
</CopyToClipboard>
)}
<Button disabled={!!token || !name} variant="primary" onClick={onCreateTokenCallback}>
Create
<Label>Token Name</Label>
<div className={cx('token__inputContainer')}>
{renderTokenInput()}
{renderCopyToClipboard()}
</div>
{renderCurlExample()}
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={() => onHide()}>
{token ? 'Close' : 'Cancel'}
</Button>
{!token && (
<Button disabled={!!token || !name} variant="primary" onClick={onCreateTokenCallback}>
Create Token
</Button>
)}
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
function renderTokenInput() {
return token ? (
<Input value={token} disabled={!!token} className={cx('token__input')} />
) : (
<Input
className={cx('token__input')}
maxLength={50}
onChange={handleNameChange}
placeholder="Enter token name"
autoFocus
/>
);
}
function renderCopyToClipboard() {
if (!token) {
return null;
}
return (
<CopyToClipboard text={token} onCopy={() => openNotification('Token copied')}>
<Button className={cx('token__copyButton')}>Copy Token</Button>
</CopyToClipboard>
);
}
function renderCurlExample() {
if (!token) {
return null;
}
return (
<VerticalGroup>
<Label>Curl command example</Label>
<SourceCode showClipboardIconOnly>{getCurlExample(token, store.onCallApiUrl)}</SourceCode>
</VerticalGroup>
);
}
});
function getCurlExample(token, onCallApiUrl) {
return `curl -H "Authorization: ${token}" ${onCallApiUrl}/api/v1/integrations`;
}
export default ApiTokenForm;

View file

@ -48,8 +48,6 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
const apiTokens = apiTokenStore.getSearchResult();
const loading = !apiTokens;
const { showCreateTokenModal } = this.state;
const columns = [

View file

@ -18,3 +18,7 @@
line-height: 20px;
height: auto;
}
.instructions-link {
color: var(--primary-text-link);
}

View file

@ -109,7 +109,12 @@ const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
{`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`}
<br />
Please see{' '}
<a href={'https://grafana.com/docs/oncall/latest/open-source/#update-grafana-oncall-oss'} target="_blank" rel="noreferrer">
<a
href={'https://grafana.com/docs/oncall/latest/open-source/#update-grafana-oncall-oss'}
target="_blank"
rel="noreferrer"
className={cx('instructions-link')}
>
the update instructions
</a>
.

View file

@ -149,9 +149,13 @@ export const PluginConfigPage = (props: Props) => {
get_sync_response.version && get_sync_response.license
? ` (${get_sync_response.license}, ${get_sync_response.version})`
: '';
setPluginStatusMessage(
`Connected to OnCall${versionInfo}\n - OnCall URL: ${plugin.meta.jsonData.onCallApiUrl}\n - Grafana URL: ${plugin.meta.jsonData.grafanaUrl}`
);
let pluginStatusMessage = `Connected to OnCall${versionInfo}\n - OnCall URL: ${plugin.meta.jsonData.onCallApiUrl}\n`
if (plugin.meta.jsonData.grafanaUrl) {
pluginStatusMessage = `${pluginStatusMessage} - Grafana URL: ${plugin.meta.jsonData.grafanaUrl}`
}
setPluginStatusMessage(pluginStatusMessage)
setIsSelfHostedInstall(plugin.meta.jsonData?.license === GRAFANA_LICENSE_OSS);
setPluginStatusOk(true);
} else {

View file

@ -27,6 +27,8 @@ interface RemoteSelectProps {
isMulti?: boolean;
openMenuOnFocus?: boolean;
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
showError?: boolean;
maxMenuHeight?: number;
}
const RemoteSelect = inject('store')(
@ -46,6 +48,8 @@ const RemoteSelect = inject('store')(
allowClear,
getOptionLabel,
openMenuOnFocus = true,
showError,
maxMenuHeight,
} = props;
const [options, setOptions] = useState<SelectableValue[] | undefined>();
@ -98,6 +102,7 @@ const RemoteSelect = inject('store')(
return (
// @ts-ignore
<Tag
maxMenuHeight={maxMenuHeight}
menuShouldPortal
openMenuOnFocus={openMenuOnFocus}
isClearable={allowClear}
@ -111,6 +116,7 @@ const RemoteSelect = inject('store')(
defaultOptions={options}
loadOptions={loadOptionsCallback}
getOptionLabel={getOptionLabel}
invalid={showError}
/>
);
})

View file

@ -0,0 +1,3 @@
export const getLabel = (layerIndex: number, rotationIndex) => {
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
};

View file

@ -0,0 +1,71 @@
.root {
transition: background-color 300ms;
min-height: 28px;
}
.loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.root:last-child {
padding-bottom: 26px;
}
.root:hover {
background: var(--secondary-background);
}
.timeline {
display: flex;
flex-direction: column;
gap: 5px;
padding-bottom: 4px;
overflow: hidden;
}
.root:first-child .timeline {
padding-top: 26px;
}
.root:last-child .timeline {
padding-bottom: 0;
}
.slots {
width: 100%;
display: flex;
transition: opacity 500ms ease;
opacity: 1;
}
.slots__animate {
transition: transform 500ms ease;
}
.slots__transparent {
opacity: 0.5;
}
.current-time {
position: absolute;
left: 450px;
width: 1px;
background: #fff;
top: -10px;
bottom: -10px;
z-index: 1;
}
.empty {
height: 28px;
cursor: pointer;
/* background: #5f505633;
border: 1px dashed #5c474d;
color: rgba(209, 14, 92, 0.5); */
margin: 0 2px;
}

View file

@ -0,0 +1,151 @@
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot';
import { getFromString } from 'models/schedule/schedule.helpers';
import { Rotation as RotationType, Schedule, Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { usePrevious } from 'utils/hooks';
import { getLabel } from './Rotation.helpers';
import styles from './Rotation.module.css';
const cx = cn.bind(styles);
interface ScheduleSlotState {}
interface RotationProps {
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
layerIndex?: number;
rotationIndex?: number;
color?: string;
events: Event[];
onClick?: (moment: dayjs.Dayjs) => void;
days?: number;
transparent?: boolean;
}
const Rotation: FC<RotationProps> = (props) => {
const {
events,
scheduleId,
layerIndex,
rotationIndex,
startMoment,
currentTimezone,
color,
onClick,
days = 7,
transparent = false,
} = props;
const [animate, setAnimate] = useState<boolean>(true);
const [width, setWidth] = useState<number | undefined>();
const startMomentString = useMemo(() => getFromString(startMoment), [startMoment]);
const prevStartMomentString = usePrevious(startMomentString);
// console.log(events);
// const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString];
/* useEffect(() => {
setTransparent(false);
}, [rotation]);
useEffect(() => {
setTransparent(true);
}, [startMoment]);*/
useEffect(() => {
const startMomentString = startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
// console.log('CHANGE START MOMENT', startMomentString);
// store.scheduleStore.updateEvents(scheduleId, startMomentString, currentTimezone);
}, [startMomentString]);
const slots = useCallback((node) => {
if (node) {
setWidth(node.offsetWidth);
}
}, []);
const handleClick = (event) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left; //x position within the element.
const width = event.currentTarget.offsetWidth;
const dayOffset = Math.floor((x / width) * 7);
onClick(startMoment.add(dayOffset, 'day'));
};
const x = useMemo(() => {
if (!events || !events.length) {
return 0;
}
const firstShift = events[0];
const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'seconds');
const base = 60 * 60 * 24 * days;
// const utcOffset = dayjs().tz(currentTimezone).utcOffset();
return firstShiftOffset / base;
}, [events]);
let eventIndexToShowLabel = -1;
if (!isNaN(layerIndex) && !isNaN(rotationIndex)) {
eventIndexToShowLabel = events.findIndex((event) => dayjs(event.start).isSameOrAfter(startMoment));
}
return (
<div className={cx('root')} onClick={handleClick}>
<div className={cx('timeline')}>
{events ? (
events.length ? (
<div
className={cx('slots', { slots__animate: animate, slots__transparent: transparent })}
style={{ transform: `translate(${x * 100}%, 0)` }}
>
{events.map((event, index) => {
return (
<ScheduleSlot
scheduleId={scheduleId}
key={event.start}
event={event}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={color}
label={index === eventIndexToShowLabel && getLabel(layerIndex, rotationIndex)}
/>
);
})}
</div>
) : (
<Empty />
)
) : (
<HorizontalGroup align="center" justify="center">
<LoadingPlaceholder text="Loading shifts..." />
</HorizontalGroup>
)}
</div>
</div>
);
};
const Empty = () => {
return <div className={cx('empty')} />;
};
export default Rotation;

View file

@ -0,0 +1,89 @@
import React, { useCallback, useMemo } from 'react';
import { DateTime, dateTime } from '@grafana/data';
import { DatePickerWithInput, HorizontalGroup, TimeOfDayPicker, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { Moment } from 'moment-timezone';
import { Timezone } from 'models/timezone/timezone.types';
import { getUserNotificationsSummary } from 'models/user/user.helpers';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from 'containers/UserTooltip/UserTooltip.module.css';
const cx = cn.bind(styles);
interface UserTooltipProps {
value: dayjs.Dayjs;
timezone: Timezone;
onChange: (value: dayjs.Dayjs) => void;
disabled?: boolean;
minMoment?: dayjs.Dayjs;
}
const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => {
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? moment : moment.tz(timezone);
return new Date(
localMoment.get('year'),
localMoment.get('month'),
localMoment.get('date'),
localMoment.get('hour'),
localMoment.get('minute'),
localMoment.get('second')
);
};
const DateTimePicker = (props: UserTooltipProps) => {
const { value: propValue, minMoment, timezone, onChange, disabled } = props;
const value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]);
const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]);
const handleDateChange = useCallback(
(newDate: Date) => {
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone);
const newValue = localMoment
.set('year', newDate.getFullYear())
.set('month', newDate.getMonth())
.set('date', newDate.getDate())
.set('hour', value.getHours())
.set('minute', value.getMinutes())
.set('second', value.getSeconds());
onChange(newValue);
},
[value]
);
const handleTimeChange = useCallback(
(newMoment: DateTime) => {
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone);
const newDate = newMoment.toDate();
const newValue = localMoment
.set('year', value.getFullYear())
.set('month', value.getMonth())
.set('date', value.getDate())
.set('hour', newDate.getHours())
.set('minute', newDate.getMinutes())
.set('second', newDate.getSeconds());
onChange(newValue);
},
[value]
);
return (
<HorizontalGroup spacing="sm">
<DatePickerWithInput minDate={minDate} disabled={disabled} value={value} onChange={handleDateChange} />
<TimeOfDayPicker disabled={disabled} value={dateTime(value)} onChange={handleTimeChange} />
</HorizontalGroup>
);
};
export default DateTimePicker;

View file

@ -0,0 +1,71 @@
.root {
display: block;
}
.draggable {
top: 0;
/* transition: transform 300ms ease; */
}
.header {
width: 100%;
display: flex;
justify-content: space-between;
}
.control {
width: 195px;
}
.user-title {
padding: 6px 10px;
z-index: 1;
color: #fff;
}
.working-hours {
position: absolute;
top: 0;
left: 0;
height: 100%;
pointer-events: none;
}
.inline-switch {
height: 18px;
}
.days {
display: flex;
gap: 14px;
width: 100%;
}
.day {
width: 28px;
height: 28px;
background: var(--secondary-background-shade);
border-radius: 2px;
line-height: 28px;
text-align: center;
cursor: pointer;
}
.days .day__selected {
background: #3d71d9;
}
.two-fields {
display: flex;
gap: 8px;
align-items: flex-start;
}
.two-fields > div {
width: 50%;
}
.content {
margin: 8px 0 16px 0;
}

View file

@ -0,0 +1,434 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { dateTime, DateTime } from '@grafana/data';
import {
IconButton,
VerticalGroup,
HorizontalGroup,
Field,
Input,
Button,
Select,
InlineSwitch,
DatePickerWithInput,
TimeOfDayPicker,
} from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Draggable from 'react-draggable';
import Modal from 'components/Modal/Modal';
import Text from 'components/Text/Text';
import UserGroups from 'components/UserGroups/UserGroups';
import { Item } from 'components/UserGroups/UserGroups.types';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import { getFromString } from 'models/schedule/schedule.helpers';
import { Rotation, Schedule, Shift } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { getCoords, waitForElement } from 'utils/DOM';
import { useDebouncedCallback } from 'utils/hooks';
import DateTimePicker from './DateTimePicker';
import { RotationCreateData } from './RotationForm.types';
import styles from './RotationForm.module.css';
interface RotationFormProps {
layerPriority: number;
onHide: () => void;
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
shiftId: Shift['id'] | 'new';
shiftMoment?: dayjs.Dayjs;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
shiftColor?: string;
}
const cx = cn.bind(styles);
const repeatShiftsEveryOptions = Array.from(Array(31).keys())
.slice(1)
.map((i) => ({ label: String(i), value: i }));
const RotationForm: FC<RotationFormProps> = observer((props) => {
const {
onHide,
onCreate,
startMoment,
currentTimezone,
scheduleId,
onUpdate,
onDelete,
layerPriority,
shiftId,
shiftMoment = dayjs().startOf('isoWeek'),
shiftColor = '#3D71D9',
} = props;
// console.log('shiftColor', shiftColor);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
const [repeatEveryPeriod, setRepeatEveryPeriod] = useState<number>(0);
const [selectedDays, setSelectedDays] = useState<string[]>([]);
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(shiftMoment);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(shiftMoment.add(1, 'day'));
const [rotationStart, setRotationStart] = useState<dayjs.Dayjs>(shiftMoment);
const [endLess, setEndless] = useState<boolean>(true);
const [rotationEnd, setRotationEnd] = useState<dayjs.Dayjs>(shiftMoment.add(1, 'month'));
useEffect(() => {
if (rotationStart.isBefore(shiftStart)) {
setRotationStart(shiftStart);
}
}, [rotationStart, shiftStart]);
const store = useStore();
const shift = store.scheduleStore.shifts[shiftId];
const [offsetTop, setOffsetTop] = useState<number>(0);
useEffect(() => {
if (isOpen) {
waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`).then((elm) => {
const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement;
const coords = getCoords(elm);
// elm.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
// setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0));
const offsetTop = Math.min(
Math.max(coords.top - modal?.offsetHeight - 10, 10),
document.body.offsetHeight - modal?.offsetHeight - 10
);
setOffsetTop(offsetTop);
});
}
}, [isOpen]);
const [userGroups, setUserGroups] = useState([[]]);
const renderUser = (userPk: User['pk']) => {
const name = store.userStore.items[userPk]?.username;
const desc = store.userStore.items[userPk]?.timezone;
const workingHours = store.userStore.items[userPk]?.working_hours;
const timezone = store.userStore.items[userPk]?.timezone;
return (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
</div>
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(params.shift_start)}
duration={dayjs(params.shift_end).diff(dayjs(params.shift_start), 'seconds')}
className={cx('working-hours')}
style={{ backgroundColor: shiftColor }}
/>
</>
);
};
const handleDeleteClick = useCallback(() => {
store.scheduleStore.deleteOncallShift(shiftId).then(() => {
onDelete();
});
}, []);
useEffect(() => {
if (shiftId !== 'new') {
store.scheduleStore.updateOncallShift(shiftId);
}
}, [shiftId]);
const params = useMemo(
() => ({
rotation_start: getUTCString(rotationStart),
until: endLess ? null : getUTCString(rotationEnd),
shift_start: getUTCString(shiftStart),
shift_end: getUTCString(shiftEnd),
rolling_users: userGroups,
interval: repeatEveryValue,
frequency: repeatEveryPeriod,
by_day: repeatEveryPeriod === 1 ? selectedDays : null,
priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level,
}),
[
rotationStart,
currentTimezone,
rotationEnd,
shiftStart,
shiftEnd,
userGroups,
repeatEveryValue,
repeatEveryPeriod,
selectedDays,
shiftId,
layerPriority,
shift,
endLess,
]
);
const handleCreate = useCallback(() => {
if (shiftId === 'new') {
store.scheduleStore.createRotation(scheduleId, false, params).then(() => {
onCreate();
});
} else {
store.scheduleStore.updateRotation(shiftId, params).then(() => {
onUpdate();
});
}
}, [scheduleId, shiftId, params]);
useEffect(() => {
if (shiftId === 'new') {
updatePreview();
}
}, []);
const updatePreview = () => {
store.scheduleStore
.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params)
.then(() => {
setIsOpen(true);
});
};
const handleChange = useDebouncedCallback(updatePreview, 200);
useEffect(handleChange, [params]);
useEffect(() => {
if (shift) {
setRotationStart(getDateTime(shift.rotation_start));
setRotationEnd(shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month'));
setShiftStart(getDateTime(shift.shift_start));
setShiftEnd(getDateTime(shift.shift_end));
setEndless(!shift.until);
setRepeatEveryValue(shift.interval);
setRepeatEveryPeriod(shift.frequency);
setSelectedDays(shift.by_day || []);
setUserGroups(shift.rolling_users);
}
}, [shift]);
const handleChangeEndless = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setEndless(!event.currentTarget.checked);
},
[endLess]
);
const handleRepeatEveryValueChange = useCallback((option) => {
setRepeatEveryValue(option.value);
}, []);
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
const moment = dayjs();
return (
<Modal
isOpen={isOpen}
width="430px"
onDismiss={onHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
<div {...props}>{children}</div>
</Draggable>
)}
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">
<HorizontalGroup spacing="sm">
<span>[L{shiftId === 'new' ? layerPriority : shift?.priority_level}]</span>
{shiftId === 'new' ? 'New Rotation' : 'Update Rotation'}
</HorizontalGroup>
</Text>
<HorizontalGroup>
{/*<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />*/}
{shiftId !== 'new' && (
<WithConfirm>
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
</WithConfirm>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
</HorizontalGroup>
</HorizontalGroup>
{/*<hr />*/}
<div className={cx('content')}>
<VerticalGroup>
<div className={cx('two-fields')}>
<Field
label={
<Text type="primary" size="small">
Rotation start
</Text>
}
>
<DateTimePicker
minMoment={shiftStart}
value={rotationStart}
onChange={setRotationStart}
timezone={currentTimezone}
/>
</Field>
<Field
label={
<HorizontalGroup spacing="xs">
<Text type="primary" size="small">
Rotation end
</Text>
<InlineSwitch
className={cx('inline-switch')}
transparent
value={!endLess}
onChange={handleChangeEndless}
/>
</HorizontalGroup>
}
>
{endLess ? (
<div style={{ lineHeight: '32px' }}>
<Text type="secondary">Endless</Text>
</div>
) : (
<DateTimePicker value={rotationEnd} onChange={setRotationEnd} timezone={currentTimezone} />
)}
</Field>
</div>
<HorizontalGroup>
<Field className={cx('control')} label="Repeat shifts every">
<Select
maxMenuHeight={120}
value={repeatEveryValue}
options={repeatShiftsEveryOptions}
onChange={handleRepeatEveryValueChange}
allowCustomValue
/>
</Field>
<Field className={cx('control')} label="">
<RemoteSelect
href="/oncall_shifts/frequency_options/"
value={repeatEveryPeriod}
onChange={setRepeatEveryPeriod}
/>
</Field>
</HorizontalGroup>
{repeatEveryPeriod === 1 && (
/*<HorizontalGroup justify="center">*/
<Field label="Select days to repeat">
<DaysSelector
options={store.scheduleStore.byDayOptions}
value={selectedDays}
onChange={(value) => setSelectedDays(value)}
/>
</Field>
/*</HorizontalGroup>*/
)}
<div className={cx('two-fields')}>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Shift start
</Text>
}
>
<DateTimePicker value={shiftStart} onChange={setShiftStart} timezone={currentTimezone} />
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Shift end
</Text>
}
>
<DateTimePicker value={shiftEnd} onChange={setShiftEnd} timezone={currentTimezone} />
</Field>
</div>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={true}
renderUser={renderUser}
showError={!isFormValid}
/>
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
{shiftId === 'new' ? 'Cancel' : 'Close'}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
});
interface DaysSelectorProps {
value: string[];
onChange: (value: string[]) => void;
options: SelectOption[];
}
const DaysSelector = ({ value, onChange, options }: DaysSelectorProps) => {
const getDayClickHandler = (day: string) => {
return () => {
const newValue = [...value];
if (newValue.includes(day)) {
const index = newValue.indexOf(day);
newValue.splice(index, 1);
} else {
newValue.push(day);
}
onChange(newValue);
};
};
return (
<div className={cx('days')}>
{options.map(({ display_name, value: itemValue }) => (
<div
onClick={getDayClickHandler(itemValue as string)}
className={cx('day', { day__selected: value.includes(itemValue as string) })}
>
{display_name.charAt(0)}
</div>
))}
</div>
);
};
export default RotationForm;

View file

@ -0,0 +1,3 @@
export interface RotationCreateData {}
export interface RotationData {}

View file

@ -0,0 +1,251 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { dateTime, DateTime } from '@grafana/data';
import { IconButton, VerticalGroup, HorizontalGroup, Field, Input, Button, Select, InlineSwitch } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Draggable from 'react-draggable';
import Modal from 'components/Modal/Modal';
import Text from 'components/Text/Text';
import UserGroups from 'components/UserGroups/UserGroups';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import { getFromString } from 'models/schedule/schedule.helpers';
import { Rotation, Schedule, Shift } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
import { useStore } from 'state/useStore';
import { getCoords, waitForElement } from 'utils/DOM';
import { useDebouncedCallback } from 'utils/hooks';
import DateTimePicker from './DateTimePicker';
import { RotationCreateData } from './RotationForm.types';
import styles from './RotationForm.module.css';
interface RotationFormProps {
onHide: () => void;
shiftId: Shift['id'] | 'new';
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
shiftMoment: dayjs.Dayjs;
shiftColor?: string;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
}
const cx = cn.bind(styles);
const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
const {
onHide,
onCreate,
currentTimezone,
scheduleId,
onUpdate,
onDelete,
shiftId,
startMoment,
shiftMoment = dayjs().startOf('day').add(1, 'day'),
shiftColor = '#C69B06',
} = props;
const store = useStore();
const [offsetTop, setOffsetTop] = useState<number>(0);
const [isOpen, setIsOpen] = useState<boolean>(false);
useEffect(() => {
if (isOpen) {
waitForElement('#overrides-list').then((elm) => {
const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement;
const coords = getCoords(elm);
const offsetTop = Math.min(
Math.max(coords.top - modal?.offsetHeight - 10, 10),
document.body.offsetHeight - modal?.offsetHeight - 10
);
setOffsetTop(offsetTop);
});
}
}, [isOpen]);
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(shiftMoment);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(shiftMoment.add(24, 'hours'));
const [userGroups, setUserGroups] = useState([[]]);
const renderUser = (userPk: User['pk']) => {
const name = store.userStore.items[userPk]?.username;
const desc = store.userStore.items[userPk]?.timezone;
const workingHours = store.userStore.items[userPk]?.working_hours;
const timezone = store.userStore.items[userPk]?.timezone;
return (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
</div>
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(params.shift_start)}
duration={dayjs(params.shift_end).diff(dayjs(params.shift_start), 'seconds')}
className={cx('working-hours')}
style={{ backgroundColor: shiftColor }}
/>
</>
);
};
const shift = store.scheduleStore.shifts[shiftId];
useEffect(() => {
if (shiftId !== 'new') {
store.scheduleStore.updateOncallShift(shiftId);
}
}, [shiftId]);
const params = useMemo(
() => ({
rotation_start: getUTCString(shiftStart),
shift_start: getUTCString(shiftStart),
shift_end: getUTCString(shiftEnd),
rolling_users: userGroups,
frequency: null,
}),
[currentTimezone, shiftStart, shiftEnd, userGroups]
);
useEffect(() => {
if (shift) {
setShiftStart(getDateTime(shift.shift_start));
setShiftEnd(getDateTime(shift.shift_end));
setUserGroups(shift.rolling_users);
}
}, [shift]);
const handleDeleteClick = useCallback(() => {
store.scheduleStore.deleteOncallShift(shiftId).then(() => {
onHide();
onDelete();
});
}, []);
const handleCreate = useCallback(() => {
if (shiftId === 'new') {
store.scheduleStore.createRotation(scheduleId, true, params).then(() => {
onCreate();
});
} else {
store.scheduleStore.updateRotation(shiftId, params).then(() => {
onUpdate();
});
}
}, [scheduleId, shiftId, params]);
useEffect(() => {
if (shiftId === 'new') {
updatePreview();
}
}, []);
const updatePreview = () => {
store.scheduleStore
.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params)
.then(() => {
setIsOpen(true);
});
};
const handleChange = useDebouncedCallback(updatePreview, 200);
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
useEffect(handleChange, [params]);
return (
<Modal
isOpen={isOpen}
width="430px"
onDismiss={onHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
<div {...props}>{children}</div>
</Draggable>
)}
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">{shiftId === 'new' ? 'New Override' : 'Update Override'}</Text>
<HorizontalGroup>
{/*<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />*/}
{shiftId !== 'new' && (
<WithConfirm>
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
</WithConfirm>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('content')}>
<VerticalGroup>
<HorizontalGroup>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override start
</Text>
}
>
<DateTimePicker value={shiftStart} onChange={setShiftStart} timezone={currentTimezone} />
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override end
</Text>
}
>
<DateTimePicker value={shiftEnd} onChange={setShiftEnd} timezone={currentTimezone} />
</Field>
</HorizontalGroup>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={false}
renderUser={renderUser}
showError={!isFormValid}
/>
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
{shiftId === 'new' ? 'Cancel' : 'Close'}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
};
export default ScheduleOverrideForm;

View file

@ -0,0 +1,4 @@
export const DEFAULT_TRANSITION_TIMEOUT = {
enter: 500,
exit: 0,
};

View file

@ -0,0 +1,39 @@
import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers';
import { Layer, Shift } from 'models/schedule/schedule.types';
export const findColor = (shiftId: Shift['id'], layers: Layer[], overrides?) => {
let color = undefined;
let layerIndex = -1;
let rotationIndex = -1;
if (layers) {
outer: for (var i = 0; i < layers.length; i++) {
for (var j = 0; j < layers[i].shifts.length; j++) {
const shift = layers[i].shifts[j];
if (shift.shiftId === shiftId || (shiftId === 'new' && shift.isPreview)) {
layerIndex = i;
rotationIndex = j;
break outer;
}
}
}
}
let overrideIndex = -1;
if (layerIndex === -1 && rotationIndex === -1 && overrides) {
for (var k = 0; k < overrides.length; k++) {
const shift = overrides[k];
if (shift.shiftId === shiftId || (shiftId === 'new' && shift.isPreview)) {
overrideIndex = k;
}
}
}
if (layerIndex > -1 && rotationIndex > -1) {
color = getColor(layerIndex, rotationIndex);
} else if (overrideIndex > -1) {
color = getOverrideColor(overrideIndex);
}
return color;
};

View file

@ -0,0 +1,113 @@
.root {
border: var(--rotations-border);
border-radius: 2px;
background: var(--rotations-background);
}
.current-time {
position: absolute;
width: 1px;
background: #fff;
top: 0;
bottom: 0;
z-index: 1;
transition: left 500ms ease;
}
.header {
padding: 0 10px;
}
.title {
font-weight: 500;
font-size: 19px;
line-height: 24px;
color: rgba(204, 204, 220, 0.65);
margin: 16px 0;
}
.rotations-plus-title {
display: flex;
flex-direction: column;
}
.layer {
display: block;
}
.rotations {
position: relative;
}
.layer-title {
text-align: center;
font-weight: 500;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.1em;
color: rgba(204, 204, 220, 0.65);
text-transform: uppercase;
padding: 8px;
background: var(--secondary-background);
}
.layer-title:hover {
background: rgba(204, 204, 220, 0.12);
}
.header-plus-content {
position: relative;
}
.layer-header {
padding: 12px;
display: flex;
justify-content: space-between;
}
.layer-header-title {
font-weight: 400;
font-size: 14px;
line-height: 20px;
color: rgba(204, 204, 220, 0.65);
}
.layer-content {
position: relative;
}
.add-rotations-layer {
font-weight: 400;
font-size: 12px;
line-height: 16px;
text-align: center;
padding: 12px;
color: rgba(204, 204, 220, 0.65);
cursor: pointer;
}
.add-rotations-layer:hover {
background: var(--secondary-background);
}
/*
animation
*/
.enter {
opacity: 0;
}
.enterActive {
opacity: 1;
transition: opacity 500ms ease-in;
}
.exit {
opacity: 1;
}
.exitActive {
opacity: 0;
transition: opacity 500ms ease-in;
}

View file

@ -0,0 +1,272 @@
import React, { Component, useMemo, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { ValuePicker, IconButton, Icon, HorizontalGroup, Button, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { toJS } from 'mobx';
import { observer } from 'mobx-react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import RotationForm from 'containers/RotationForm/RotationForm';
import { RotationCreateData } from 'containers/RotationForm/RotationForm.types';
import { getColor, getFromString } from 'models/schedule/schedule.helpers';
import { Event, Layer, Schedule, Shift } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { SelectOption, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findColor } from './Rotations.helpers';
import styles from './Rotations.module.css';
const cx = cn.bind(styles);
interface RotationsProps extends WithStoreProps {
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
shiftIdToShowRotationForm?: Shift['id'] | 'new';
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
onClick: (id: Shift['id'] | 'new') => void;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
}
interface RotationsState {
layerPriority?: Layer['priority'];
shiftMomentToShowRotationForm?: dayjs.Dayjs;
}
@observer
class Rotations extends Component<RotationsProps, RotationsState> {
state: RotationsState = {
layerPriority: undefined,
shiftMomentToShowRotationForm: undefined,
};
render() {
const {
scheduleId,
startMoment,
currentTimezone,
onCreate,
onUpdate,
onDelete,
store,
onClick,
shiftIdToShowRotationForm,
} = this.props;
const { layerPriority, shiftMomentToShowRotationForm } = this.state;
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
const currentTimeX = diff / base;
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
const layers = store.scheduleStore.rotationPreview
? store.scheduleStore.rotationPreview
: (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]);
const options = layers
? layers.map((layer) => ({
label: `Layer ${layer.priority}`,
value: layer.priority,
}))
: [];
const nextPriority = layers && layers.length ? layers[layers.length - 1].priority + 1 : 1;
options.push({ label: 'New Layer', value: nextPriority });
return (
<>
<div className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Rotations</div>
<ValuePicker
label="Add rotation"
options={options}
onChange={this.handleAddRotation}
variant="secondary"
size="md"
/>
</HorizontalGroup>
</div>
<div className={cx('rotations-plus-title')}>
{layers && layers.length ? (
<TransitionGroup className={cx('layers')}>
{layers.map((layer, layerIndex) => (
<CSSTransition key={layerIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<div id={`layer${layer.priority}`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer {layer.priority}</span>
{/*<Icon name="info-circle" />*/}
</HorizontalGroup>
</div>
<div className={cx('rotations')}>
<TimelineMarks startMoment={startMoment} />
{!currentTimeHidden && (
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
)}
<TransitionGroup className={cx('rotations')}>
{layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
<CSSTransition
key={rotationIndex}
timeout={DEFAULT_TRANSITION_TIMEOUT}
classNames={{ ...styles }}
>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
this.onRotationClick(shiftId, moment);
}}
color={getColor(layerIndex, rotationIndex)}
events={events}
layerIndex={layerIndex}
rotationIndex={rotationIndex}
startMoment={startMoment}
currentTimezone={currentTimezone}
transparent={isPreview}
/>
</CSSTransition>
))}
</TransitionGroup>
</div>
</div>
</CSSTransition>
))}
</TransitionGroup>
) : (
<div>
<div id={`layer1`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer 1</span>
{/* <Icon name="info-circle" />*/}
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
<TimelineMarks startMoment={startMoment} />
<div className={cx('rotations')}>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
this.handleAddLayer(nextPriority, moment);
}}
events={[]}
layerIndex={0}
rotationIndex={0}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
</div>
</div>
</div>
</div>
)}
{nextPriority > 1 && (
<div
className={cx('add-rotations-layer')}
onClick={() => {
this.handleAddLayer(nextPriority);
}}
>
+ Add rotations layer
</div>
)}
</div>
</div>
{shiftIdToShowRotationForm && (
<RotationForm
shiftId={shiftIdToShowRotationForm}
shiftColor={findColor(shiftIdToShowRotationForm, layers)}
scheduleId={scheduleId}
layerPriority={layerPriority}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowRotationForm}
onHide={() => {
this.hideRotationForm();
store.scheduleStore.clearPreview();
}}
onUpdate={() => {
this.hideRotationForm();
onUpdate();
}}
onCreate={() => {
this.hideRotationForm();
onCreate();
}}
onDelete={() => {
this.hideRotationForm();
onDelete();
}}
/>
)}
</>
);
}
onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => {
this.setState({ shiftMomentToShowRotationForm: moment }, () => {
this.onShowRotationForm(shiftId);
});
};
handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => {
this.setState({ layerPriority, shiftMomentToShowRotationForm: moment }, () => {
this.onShowRotationForm('new');
});
};
handleAddRotation = (option: SelectableValue) => {
const { startMoment } = this.props;
this.setState(
{
layerPriority: option.value,
shiftMomentToShowRotationForm: startMoment,
},
() => {
this.onShowRotationForm('new');
}
);
};
hideRotationForm = () => {
const { store } = this.props;
this.setState(
{
layerPriority: undefined,
shiftMomentToShowRotationForm: undefined,
},
() => {
this.onShowRotationForm(undefined);
}
);
};
onShowRotationForm = (shiftId: Shift['id']) => {
const { onShowRotationForm } = this.props;
onShowRotationForm(shiftId);
};
}
export default withMobXProviderContext(Rotations);

View file

@ -0,0 +1,134 @@
import React, { Component, useEffect } from 'react';
import { Button, HorizontalGroup, Icon, Input, ValuePicker } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import { getColor, getFromString, getOverrideColor } from 'models/schedule/schedule.helpers';
import { Event, Layer, Schedule, Shift } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findColor } from './Rotations.helpers';
import styles from './Rotations.module.css';
const cx = cn.bind(styles);
interface ScheduleFinalProps extends WithStoreProps {
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
hideHeader?: boolean;
onClick: (shiftId: Shift['id']) => void;
}
interface ScheduleOverridesState {
searchTerm: string;
}
@observer
class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState> {
state: ScheduleOverridesState = {
searchTerm: '',
};
render() {
const { scheduleId, startMoment, currentTimezone, store, hideHeader } = this.props;
const { searchTerm } = this.state;
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
const currentTimeX = diff / base;
const shifts = store.scheduleStore.finalPreview
? store.scheduleStore.finalPreview
: (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as Array<{
shiftId: string;
events: Event[];
}>);
const layers = store.scheduleStore.rotationPreview
? store.scheduleStore.rotationPreview
: (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]);
const overrides = store.scheduleStore.overridePreview
? store.scheduleStore.overridePreview
: store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)];
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
/* console.log('shifts', toJS(shifts));
console.log('layers', toJS(layers)); */
return (
<>
<div className={cx('root')}>
{!hideHeader && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Final schedule</div>
{/*<Input
prefix={<Icon name="search" />}
placeholder="Search..."
value={searchTerm}
onChange={this.onSearchTermChangeCallback}
/>*/}
</HorizontalGroup>
</div>
)}
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={index}
scheduleId={scheduleId}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={findColor(shiftId, layers, overrides)}
onClick={this.getRotationClickHandler(shiftId)}
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
</div>
</>
);
}
getRotationClickHandler = (shiftId: Shift['id']) => {
const { onClick } = this.props;
return () => {
onClick(shiftId);
};
};
onSearchTermChangeCallback = () => {};
}
export default withMobXProviderContext(ScheduleFinal);

View file

@ -0,0 +1,179 @@
import React, { Component } from 'react';
import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import { RotationCreateData } from 'containers/RotationForm/RotationForm.types';
import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm';
import { getFromString, getOverrideColor } from 'models/schedule/schedule.helpers';
import { Event, Schedule, Shift } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import { findColor } from './Rotations.helpers';
import styles from './Rotations.module.css';
const cx = cn.bind(styles);
interface ScheduleOverridesProps extends WithStoreProps {
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
shiftIdToShowRotationForm?: Shift['id'] | 'new';
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
}
interface ScheduleOverridesState {
shiftMomentToShowOverrideForm?: dayjs.Dayjs;
}
@observer
class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverridesState> {
state: ScheduleOverridesState = {
shiftMomentToShowOverrideForm: undefined,
};
render() {
const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, shiftIdToShowRotationForm } =
this.props;
const { shiftMomentToShowOverrideForm } = this.state;
const shifts = store.scheduleStore.overridePreview
? store.scheduleStore.overridePreview
: (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as Array<{
shiftId: string;
events: Event[];
isPreview?: boolean;
}>);
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
const currentTimeX = diff / base;
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
return (
<>
<div id="overrides-list" className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Overrides</div>
<Button icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
<CSSTransition key={rotationIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={rotationIndex}
scheduleId={scheduleId}
events={events}
color={getOverrideColor(rotationIndex)}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick(shiftId, moment);
}}
transparent={isPreview}
/>
</CSSTransition>
))
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
events={[]}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick('new', moment);
}}
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
{/* <div className={cx('add-rotations-layer')} onClick={this.handleAddOverride}>
+ Add override
</div>*/}
</div>
{shiftIdToShowRotationForm && (
<ScheduleOverrideForm
shiftId={shiftIdToShowRotationForm}
shiftColor={findColor(shiftIdToShowRotationForm, undefined, shifts)}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowOverrideForm}
onHide={() => {
this.handleHide();
store.scheduleStore.clearPreview();
}}
onUpdate={() => {
this.handleHide();
onUpdate();
}}
onCreate={() => {
this.handleHide();
onCreate();
}}
onDelete={() => {
this.handleHide();
onDelete();
}}
/>
)}
</>
);
}
onRotationClick = (shiftId: Shift['id'], moment: dayjs.Dayjs) => {
this.setState({ shiftMomentToShowOverrideForm: moment }, () => {
this.onShowRotationForm(shiftId);
});
};
handleAddOverride = () => {
const { startMoment } = this.props;
this.setState({ shiftMomentToShowOverrideForm: startMoment }, () => {
this.onShowRotationForm('new');
});
};
handleHide = () => {
this.setState({ shiftMomentToShowOverrideForm: undefined }, () => {
this.onShowRotationForm(undefined);
});
};
onShowRotationForm = (shiftId: Shift['id']) => {
const { onShowRotationForm } = this.props;
onShowRotationForm(shiftId);
};
}
export default withMobXProviderContext(ScheduleOverrides);

View file

@ -143,3 +143,14 @@ export const calendarForm: { name: string; fields: FormItem[] } = {
...commonFields,
],
};
export const apiForm: { name: string; fields: FormItem[] } = {
name: 'Schedule',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
],
};

View file

@ -13,7 +13,7 @@ import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
import { useStore } from 'state/useStore';
import { UserAction } from 'state/userAction';
import { calendarForm, iCalForm } from './ScheduleForm.config';
import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config';
import { prepareForEdit } from './ScheduleForm.helpers';
import styles from './ScheduleForm.module.css';
@ -24,19 +24,25 @@ interface ScheduleFormProps {
id: Schedule['id'] | 'new';
onHide: () => void;
onUpdate: () => void;
onCreate?: (data: Schedule) => void;
type?: ScheduleType;
}
const scheduleTypeToForm = {
[ScheduleType.Calendar]: calendarForm,
[ScheduleType.Ical]: iCalForm,
[ScheduleType.API]: apiForm,
};
const ScheduleForm = observer((props: ScheduleFormProps) => {
const { id, onUpdate, onHide } = props;
const { id, type, onUpdate, onCreate, onHide } = props;
const store = useStore();
const { scheduleStore, userStore } = store;
const data = useMemo(() => {
return id === 'new'
? { team: userStore.currentUser?.current_team, type: ScheduleType.Ical }
: prepareForEdit(scheduleStore.items[id]);
return id === 'new' ? { team: userStore.currentUser?.current_team, type } : prepareForEdit(scheduleStore.items[id]);
}, [id]);
const handleSubmit = useCallback(
@ -44,16 +50,20 @@ const ScheduleForm = observer((props: ScheduleFormProps) => {
(id === 'new'
? scheduleStore.create({ ...formData, type: data.type })
: scheduleStore.update(id, { ...formData, type: data.type })
).then(() => {
).then((data) => {
onHide();
onUpdate();
if (id === 'new') {
onCreate(data);
}
});
},
[id]
);
const formConfig = data.type === ScheduleType.Ical ? iCalForm : calendarForm;
const formConfig = scheduleTypeToForm[data.type];
return (
<Drawer

View file

@ -0,0 +1,31 @@
import dayjs from 'dayjs';
import { Shift } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
const USERS = [
'Innokentii Konstantinov',
'Ildar Iskhakov',
'Matias Bordese',
'Michael Derynck',
'Vadim Stepanov',
'Matvey Kukuy',
'Yulya Artyukhina',
'Raphael Batyrbaev',
];
export const getRandomUser = () => {
return USERS[Math.floor(Math.random() * USERS.length)];
};
export const getTitle = (user: User) => {
return user ? user.username.split(' ')[0] : null;
return user
? user.username
.split(' ')
.map((word) => word.charAt(0).toUpperCase())
.join('')
: null;
};
export const getOuRanges = (shift: Shift, user: User) => {};

Some files were not shown because too many files have changed in this diff Show more