Merge branch 'dev' into 472-wrong-team
This commit is contained in:
commit
ea54d21b76
138 changed files with 8451 additions and 482 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
|||
*/db.sqlite3
|
||||
*.pyc
|
||||
venv
|
||||
.python-version
|
||||
.env
|
||||
.env_hobby
|
||||
.vscode
|
||||
|
|
|
|||
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
92
DEVELOPER.md
92
DEVELOPER.md
|
|
@ -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 → 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 → Languages & Frameworks → Django
|
||||
4. Under Settings → Languages & Frameworks → Django
|
||||
- Enable Django support
|
||||
- Set Django project root to /engine
|
||||
- Set Settings to settings/dev.py
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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+)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
grafana-plugin/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
14.17.0
|
||||
|
|
@ -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 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
33
grafana-plugin/src/components/Modal/Modal.module.css
Normal file
33
grafana-plugin/src/components/Modal/Modal.module.css
Normal 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;
|
||||
}
|
||||
50
grafana-plugin/src/components/Modal/Modal.tsx
Normal file
50
grafana-plugin/src/components/Modal/Modal.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 |
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { ScheduleType } from 'models/schedule/schedule.types';
|
||||
|
||||
export interface SchedulesFiltersType {
|
||||
searchTerm: string;
|
||||
type: ScheduleType;
|
||||
status: string;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
44
grafana-plugin/src/components/Table/Table.module.css
Normal file
44
grafana-plugin/src/components/Table/Table.module.css
Normal 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);
|
||||
}
|
||||
69
grafana-plugin/src/components/Table/Table.tsx
Normal file
69
grafana-plugin/src/components/Table/Table.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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%;
|
||||
}
|
||||
173
grafana-plugin/src/components/UserGroups/UserGroups.tsx
Normal file
173
grafana-plugin/src/components/UserGroups/UserGroups.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface Item {
|
||||
key: string;
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
97
grafana-plugin/src/components/WorkingHours/WorkingHours.tsx
Normal file
97
grafana-plugin/src/components/WorkingHours/WorkingHours.tsx
Normal 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;
|
||||
|
|
@ -112,3 +112,7 @@
|
|||
.slack-channel-switch {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.description-style a {
|
||||
color: var(--primary-text-link);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
|
|||
|
||||
const apiTokens = apiTokenStore.getSearchResult();
|
||||
|
||||
const loading = !apiTokens;
|
||||
|
||||
const { showCreateTokenModal } = this.state;
|
||||
|
||||
const columns = [
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@
|
|||
line-height: 20px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.instructions-link {
|
||||
color: var(--primary-text-link);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export const getLabel = (layerIndex: number, rotationIndex) => {
|
||||
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
|
||||
};
|
||||
71
grafana-plugin/src/containers/Rotation/Rotation.module.css
Normal file
71
grafana-plugin/src/containers/Rotation/Rotation.module.css
Normal 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;
|
||||
}
|
||||
151
grafana-plugin/src/containers/Rotation/Rotation.tsx
Normal file
151
grafana-plugin/src/containers/Rotation/Rotation.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
434
grafana-plugin/src/containers/RotationForm/RotationForm.tsx
Normal file
434
grafana-plugin/src/containers/RotationForm/RotationForm.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export interface RotationCreateData {}
|
||||
|
||||
export interface RotationData {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const DEFAULT_TRANSITION_TIMEOUT = {
|
||||
enter: 500,
|
||||
exit: 0,
|
||||
};
|
||||
39
grafana-plugin/src/containers/Rotations/Rotations.helpers.ts
Normal file
39
grafana-plugin/src/containers/Rotations/Rotations.helpers.ts
Normal 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;
|
||||
};
|
||||
113
grafana-plugin/src/containers/Rotations/Rotations.module.css
Normal file
113
grafana-plugin/src/containers/Rotations/Rotations.module.css
Normal 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;
|
||||
}
|
||||
272
grafana-plugin/src/containers/Rotations/Rotations.tsx
Normal file
272
grafana-plugin/src/containers/Rotations/Rotations.tsx
Normal 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);
|
||||
134
grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx
Normal file
134
grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx
Normal 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);
|
||||
179
grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx
Normal file
179
grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx
Normal 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);
|
||||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue