diff --git a/.gitignore b/.gitignore index b00b88a2..cadd75d3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ */db.sqlite3 *.pyc venv +.python-version .env .env_hobby .vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index ffaeb731..df1284de 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/DEVELOPER.md b/DEVELOPER.md index 6612a8e7..da536813 100644 --- a/DEVELOPER.md +++ b/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 @@ -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 diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index 59b27ead..8b673b64 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -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 + } } ] } diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index db079c16..7b4681fd 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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() diff --git a/engine/apps/alerts/tests/test_utils.py b/engine/apps/alerts/tests/test_utils.py index ff19018a..7934f64a 100644 --- a/engine/apps/alerts/tests/test_utils.py +++ b/engine/apps/alerts/tests/test_utils.py @@ -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" diff --git a/engine/apps/alerts/utils.py b/engine/apps/alerts/utils.py index 58ba22ea..86cbc786 100644 --- a/engine/apps/alerts/utils.py +++ b/engine/apps/alerts/utils.py @@ -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: diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py index 9cc9e66c..be5660d3 100644 --- a/engine/apps/api/serializers/on_call_shifts.py +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -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: diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 73466515..5f428d87 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -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 diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index e5fc6a0b..efa2fb96 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -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, diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 78da25d9..54a79ad7 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -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, diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 4d63089c..cf37b9e7 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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 diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 00153a87..0fe0d8a9 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -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): diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 7c864383..2dcc4988 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -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) diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index 702f08de..2fbdf44b 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -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) diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index 5ee38fe2..a9571335 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -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) diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/incidents.py index 1d3bc174..5010f88f 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/incidents.py @@ -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): diff --git a/engine/apps/public_api/serializers/schedules_calendar.py b/engine/apps/public_api/serializers/schedules_calendar.py index 23f7f674..04f8bf50 100644 --- a/engine/apps/public_api/serializers/schedules_calendar.py +++ b/engine/apps/public_api/serializers/schedules_calendar.py @@ -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") diff --git a/engine/apps/public_api/tests/test_incidents.py b/engine/apps/public_api/tests/test_incidents.py index ea1198a0..73685c25 100644 --- a/engine/apps/public_api/tests/test_incidents.py +++ b/engine/apps/public_api/tests/test_incidents.py @@ -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): diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index b772a8ba..bfcba8f0 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -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, diff --git a/engine/apps/schedules/constants.py b/engine/apps/schedules/constants.py index 719aa0b2..a2ec8adc 100644 --- a/engine/apps/schedules/constants.py +++ b/engine/apps/schedules/constants.py @@ -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+)") diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 12fb5bd1..ac8f9596 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -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 diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index d132037a..232c824a 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -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 diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index d6abefd1..b3d72911 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -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) diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 38213be8..f6bc2155 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -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" diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index f46bb4b2..e8da0e67 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -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() diff --git a/engine/apps/slack/tests/test_parse_slack_usernames.py b/engine/apps/slack/tests/test_parse_slack_usernames.py index df43c950..659cc27a 100644 --- a/engine/apps/slack/tests/test_parse_slack_usernames.py +++ b/engine/apps/slack/tests/test_parse_slack_usernames.py @@ -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]" diff --git a/engine/apps/user_management/migrations/0003_user_hide_phone_number.py b/engine/apps/user_management/migrations/0003_user_hide_phone_number.py new file mode 100644 index 00000000..e1d3a0ed --- /dev/null +++ b/engine/apps/user_management/migrations/0003_user_hide_phone_number.py @@ -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), + ), + ] diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 041a0ec5..63c3ce0e 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -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" diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 46f9cda1..7b0c91d7 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -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") diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index f7a31e7d..d6a6d922 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -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() diff --git a/engine/common/api_helpers/paginators.py b/engine/common/api_helpers/paginators.py index 01ce2cc6..2a3ad974 100644 --- a/engine/common/api_helpers/paginators.py +++ b/engine/common/api_helpers/paginators.py @@ -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" diff --git a/engine/settings/base.py b/engine/settings/base.py index 610bedc9..3f893246 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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, diff --git a/engine/settings/dev.py b/engine/settings/dev.py index 87a82d27..fb7ddc3e 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -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 diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 65587c46..6b7c20d8 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -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 diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index c66137b2..87f1dd00 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -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', diff --git a/grafana-plugin/.nvmrc b/grafana-plugin/.nvmrc new file mode 100644 index 00000000..62df50f1 --- /dev/null +++ b/grafana-plugin/.nvmrc @@ -0,0 +1 @@ +14.17.0 diff --git a/grafana-plugin/.stylelintrc b/grafana-plugin/.stylelintrc index 9144c1e6..7cda5c91 100644 --- a/grafana-plugin/.stylelintrc +++ b/grafana-plugin/.stylelintrc @@ -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 @@ } ] } -} \ No newline at end of file +} diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 4921d53e..0093af7d 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -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", diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 05742afd..4e3f6467 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -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(() => { diff --git a/grafana-plugin/src/components/Avatar/Avatar.tsx b/grafana-plugin/src/components/Avatar/Avatar.tsx index 092e3c08..15b93552 100644 --- a/grafana-plugin/src/components/Avatar/Avatar.tsx +++ b/grafana-plugin/src/components/Avatar/Avatar.tsx @@ -13,13 +13,13 @@ interface AvatarProps { const cx = cn.bind(styles); const Avatar: FC = (props) => { - const { src, size, className } = props; + const { src, size, className, ...rest } = props; if (!src) { return null; } - return ; + return ; }; export default Avatar; diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css new file mode 100644 index 00000000..a117b44d --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -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; +} diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..869a3a25 --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -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> = (props) => { + const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props; + + return ( + {}} + onRequestClose={onDismiss} + contentLabel={title} + className={cx('root')} + overlayClassName={cx('overlay')} + bodyOpenClassName={cx('body-open')} + contentElement={contentElement} + > + {children} + + ); +}; + +export default Modal; diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css new file mode 100644 index 00000000..719dfe35 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css @@ -0,0 +1,7 @@ +.root { + display: block; +} + +.block { + width: 100%; +} diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx new file mode 100644 index 00000000..f70df5b5 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -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 = (props) => { + const { onHide, onCreate, onUpdate } = props; + + const [showScheduleForm, setShowScheduleForm] = useState(false); + const [type, setType] = useState(); + + const getCreateScheduleClickHandler = useCallback((type: ScheduleType) => { + return () => { + setType(type); + setShowScheduleForm(true); + }; + }, []); + + return ( + <> + +
+ + {/* + 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 + */} + + + + + + + Set up on-call rotation schedule + + Configure rotations and shifts directly in Grafana On-Call + + + + + + + + + + + + + + Import schedule from iCal Url + + Import rotations and shifts from your calendar app + + + + + + + + + + + + Create schedule by API + + Configure rotations and upload calendar by Terraform file + + + + + + +
+
+ {showScheduleForm && ( + { + onHide(); + onUpdate(); + }} + onCreate={onCreate} + onHide={() => { + setType(undefined); + setShowScheduleForm(false); + }} + /> + )} + + ); +}; + +export default NewScheduleSelector; diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css new file mode 100644 index 00000000..f0f2dc1f --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css @@ -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; +} +*/ diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx new file mode 100644 index 00000000..9f7293c1 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx @@ -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; + 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 = (props) => { + const { type, count, tooltipTitle, tooltipContent, onHover } = props; + + return ( + + + {tooltipTitle} + {tooltipContent} + + + } + > +
+ + + {count} + +
+
+ ); +}; + +export default ScheduleCounter; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css new file mode 100644 index 00000000..5f9d2674 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css @@ -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; +} diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx new file mode 100644 index 00000000..850ae043 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -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 = (props) => { + const { quality } = props; + + return ( + }> +
+ + Quality: + {Math.floor(quality * 100)}% + +
+
+ ); +}; + +interface ScheduleQualityDetailsProps { + quality: number; +} + +const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => { + const { quality } = props; + + const [expanded, setExpanded] = useState(false); + + const type = quality > 0.8 ? 'success' : 'warning'; + + const qualityPercent = quality * 100; + + const handleExpandClick = useCallback(() => { + setExpanded((expanded) => !expanded); + }, []); + + return ( +
+ + Schedule quality +
+
+
+ {qualityPercent}% +
{' '} +
+
+ {type === 'success' && ( + + You are doing a great job!
+ Schedule is well balanced for all members. +
+ )} + {type === 'warning' && Your schedule has balance problems.} +
+ + + + + Calculation methodology + + + + {expanded && ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies. + Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod. + + )} + +
+
+ ); +}; + +export default ScheduleQuality; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css new file mode 100644 index 00000000..84906035 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css @@ -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; +} diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx new file mode 100644 index 00000000..423fb074 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -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 = (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 ( +
+ + + + {/**/} + + + {user.username} + + {`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr} + + {/*
+ {userOncallStatusToText[userStatus]} +
+ + + Next shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+ + Last shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+
+
+
+ + Contacts + + + mail@grafana.com + + + + @slackid + + + + +39 555 449 00 00 + */} + +
+
+ ); +}; + +export default ScheduleUserDetails; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg new file mode 100644 index 00000000..6dcaf594 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts new file mode 100644 index 00000000..3cfbe028 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts @@ -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'; +} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css new file mode 100644 index 00000000..e7bbaff6 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css @@ -0,0 +1,4 @@ +.root { + display: inline-flex; + align-items: center; +} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx new file mode 100644 index 00000000..dbcbd942 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx @@ -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) => { + onChange({ ...value, searchTerm: e.currentTarget.value }); + }, + [value] + ); + const handleStatusChange = useCallback( + (status) => { + onChange({ ...value, status }); + }, + [value] + ); + + const handleTypeChange = useCallback( + (type) => { + onChange({ ...value, type }); + }, + [value] + ); + + return ( +
+ + + } + placeholder="Search..." + value={value.searchTerm} + onChange={onSearchTermChangeCallback} + /> + + + + + + + + +
+ ); +}; + +export default SchedulesFilters; diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts new file mode 100644 index 00000000..ec0ab632 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts @@ -0,0 +1,7 @@ +import { ScheduleType } from 'models/schedule/schedule.types'; + +export interface SchedulesFiltersType { + searchTerm: string; + type: ScheduleType; + status: string; +} diff --git a/grafana-plugin/src/components/SourceCode/SourceCode.module.css b/grafana-plugin/src/components/SourceCode/SourceCode.module.scss similarity index 56% rename from grafana-plugin/src/components/SourceCode/SourceCode.module.css rename to grafana-plugin/src/components/SourceCode/SourceCode.module.scss index beabde1e..0b281c05 100644 --- a/grafana-plugin/src/components/SourceCode/SourceCode.module.css +++ b/grafana-plugin/src/components/SourceCode/SourceCode.module.scss @@ -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; +} \ No newline at end of file diff --git a/grafana-plugin/src/components/SourceCode/SourceCode.tsx b/grafana-plugin/src/components/SourceCode/SourceCode.tsx index 91c3513c..de97190c 100644 --- a/grafana-plugin/src/components/SourceCode/SourceCode.tsx +++ b/grafana-plugin/src/components/SourceCode/SourceCode.tsx @@ -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 = (props) => { - const { children, noMaxHeight = false, showCopyToClipboard = true } = props; + const { children, noMaxHeight = false, showClipboardIconOnly = false, showCopyToClipboard = true } = props; + const showClipboardCopy = showClipboardIconOnly || showCopyToClipboard; return (
- {showCopyToClipboard && ( + {showClipboardCopy && ( { openNotification('Copied!'); }} > - + {showClipboardIconOnly ? ( + + ) : ( + + )} )}
         {children}
diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css
new file mode 100644
index 00000000..df6caa08
--- /dev/null
+++ b/grafana-plugin/src/components/Table/Table.module.css
@@ -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);
+}
diff --git a/grafana-plugin/src/components/Table/Table.tsx b/grafana-plugin/src/components/Table/Table.tsx
new file mode 100644
index 00000000..d639ccf0
--- /dev/null
+++ b/grafana-plugin/src/components/Table/Table.tsx
@@ -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 extends TableProps {
+  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) => {
+  const { columns, data, className, pagination, loading, rowKey, expandable, ...restProps } = props;
+
+  const { page, total: numberOfPages, onChange: onNavigate } = pagination || {};
+
+  if (expandable) {
+    expandable.expandIcon = ({ expanded, record }) => {
+      return (
+        
+ +
+ ); + }; + } + + return ( + + + {pagination && ( +
+ +
+ )} + + ); +}; + +export default GTable; diff --git a/grafana-plugin/src/components/Text/Text.module.scss b/grafana-plugin/src/components/Text/Text.module.scss index d35e483c..411ffd33 100644 --- a/grafana-plugin/src/components/Text/Text.module.scss +++ b/grafana-plugin/src/components/Text/Text.module.scss @@ -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; } diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index 7d76b937..89f8fdf2 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -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 { - 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 { onTextChange?: (value: string) => void; clearBeforeEdit?: boolean; hidden?: boolean; + editModalTitle?: string; } -interface TextType extends React.FC { +interface TextInterface extends React.FC { Title: React.FC; } @@ -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(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) => { )} {isEditMode && ( - + = (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 ( +
+ {debug && ( + + {cuts.map((cut, index) => ( + + ))} + + )} + {momentsToRender.map((m, i) => { + return ( +
+
{m.moment.format('ddd D MMM')}
+
+ {m.moments.map((mm, j) => ( +
+
+ {mm.format('HH:mm')} +
+
+ ))} +
+
+ ); + })} +
+ ); +}; + +export default TimelineMarks; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts new file mode 100644 index 00000000..dcbfb7bf --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -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); +}; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css new file mode 100644 index 00000000..178e3412 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -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%; +} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx new file mode 100644 index 00000000..affdad35 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -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>; + onChange: (value: Array>) => void; + isMultipleGroups: boolean; + renderUser: (id: string) => React.ReactElement; + showError?: boolean; +} + +const cx = cn.bind(styles); + +const DragHandle = () => ; + +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) => ( +
  • + {renderUser(item.data)} +
    + + + + +
    +
  • + ); + + return ( +
    + + + + +
    + ); +}; + +interface SortableItemProps { + children: React.ReactElement; +} + +const SortableItem = SortableElement(({ children }) => children); + +interface SortableListProps { + items: Item[]; + handleAddGroup: () => void; + handleDeleteItem: (index: number) => void; + isMultipleGroups: boolean; + renderItem: (item: Item, index: number) => React.ReactElement; +} + +const SortableList = SortableContainer(({ items, handleAddGroup, isMultipleGroups, renderItem }) => { + return ( +
      + {items.map((item, index) => + item.type === 'item' ? ( + + {renderItem(item, index)} + + ) : isMultipleGroups ? ( + +
    • {item.data.name}
    • +
      + ) : null + )} + {isMultipleGroups && items[items.length - 1]?.type === 'item' && ( + +
    • + Add user group + +
    • +
      + )} +
    + ); +}); + +export default UserGroups; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.types.ts b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts new file mode 100644 index 00000000..99d7a3c3 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts @@ -0,0 +1,5 @@ +export interface Item { + key: string; + type: string; + data: any; +} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css new file mode 100644 index 00000000..c6b6dd45 --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css @@ -0,0 +1,3 @@ +.root { + width: 300px; +} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx new file mode 100644 index 00000000..aa3fabf7 --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -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 = (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 ( +
    + - {token && ( - <> - - - )} - - {token && ( - { - openNotification('Token copied'); - }} - > - - - )} - + {!token && ( + + )} ); + + function renderTokenInput() { + return token ? ( + + ) : ( + + ); + } + + function renderCopyToClipboard() { + if (!token) { + return null; + } + return ( + openNotification('Token copied')}> + + + ); + } + + function renderCurlExample() { + if (!token) { + return null; + } + return ( + + + {getCurlExample(token, store.onCallApiUrl)} + + ); + } }); +function getCurlExample(token, onCallApiUrl) { + return `curl -H "Authorization: ${token}" ${onCallApiUrl}/api/v1/integrations`; +} + export default ApiTokenForm; diff --git a/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenSettings.tsx b/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenSettings.tsx index 71e85426..f9a9f0bb 100644 --- a/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenSettings.tsx +++ b/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenSettings.tsx @@ -48,8 +48,6 @@ class ApiTokens extends React.Component { const apiTokens = apiTokenStore.getSearchResult(); - const loading = !apiTokens; - const { showCreateTokenModal } = this.state; const columns = [ diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.module.css b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.module.css index d80b7131..e18d0545 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.module.css +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.module.css @@ -18,3 +18,7 @@ line-height: 20px; height: auto; } + +.instructions-link { + color: var(--primary-text-link); +} diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx index fd726b4a..06093eb4 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx @@ -109,7 +109,12 @@ const DefaultPageLayout: FC = observer((props) => { {`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`}
    Please see{' '} - + the update instructions . diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 8d612aa7..264315b3 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -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 { diff --git a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx index 7f061eb5..d017b149 100644 --- a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx +++ b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx @@ -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(); @@ -98,6 +102,7 @@ const RemoteSelect = inject('store')( return ( // @ts-ignore ); }) diff --git a/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts b/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts new file mode 100644 index 00000000..04369117 --- /dev/null +++ b/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts @@ -0,0 +1,3 @@ +export const getLabel = (layerIndex: number, rotationIndex) => { + return `L ${layerIndex + 1}-${rotationIndex + 1}`; +}; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css new file mode 100644 index 00000000..b721e17b --- /dev/null +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -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; +} diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx new file mode 100644 index 00000000..0add0728 --- /dev/null +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -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 = (props) => { + const { + events, + scheduleId, + layerIndex, + rotationIndex, + startMoment, + currentTimezone, + color, + onClick, + days = 7, + transparent = false, + } = props; + + const [animate, setAnimate] = useState(true); + const [width, setWidth] = useState(); + + 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 ( +
    +
    + {events ? ( + events.length ? ( +
    + {events.map((event, index) => { + return ( + + ); + })} +
    + ) : ( + + ) + ) : ( + + + + )} +
    +
    + ); +}; + +const Empty = () => { + return
    ; +}; + +export default Rotation; diff --git a/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx new file mode 100644 index 00000000..1857b5c7 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx @@ -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 ( + + + + + ); +}; + +export default DateTimePicker; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css new file mode 100644 index 00000000..b913ddb3 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -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; +} diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx new file mode 100644 index 00000000..a7a2b406 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -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 = 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(false); + + const [repeatEveryValue, setRepeatEveryValue] = useState(1); + const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); + const [selectedDays, setSelectedDays] = useState([]); + const [shiftStart, setShiftStart] = useState(shiftMoment); + const [shiftEnd, setShiftEnd] = useState(shiftMoment.add(1, 'day')); + const [rotationStart, setRotationStart] = useState(shiftMoment); + const [endLess, setEndless] = useState(true); + const [rotationEnd, setRotationEnd] = useState(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(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 ( + <> +
    + {name} ({desc}) +
    + + + ); + }; + + 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) => { + 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 ( + ( + +
    {children}
    +
    + )} + > + + + + + [L{shiftId === 'new' ? layerPriority : shift?.priority_level}] + {shiftId === 'new' ? 'New Rotation' : 'Update Rotation'} + + + + {/* + */} + {shiftId !== 'new' && ( + + + + )} + + + + {/*
    */} +
    + +
    + + Rotation start + + } + > + + + + + Rotation end + + + + } + > + {endLess ? ( +
    + Endless +
    + ) : ( + + )} +
    +
    + + + } + placeholder="Search..." + value={searchTerm} + onChange={this.onSearchTermChangeCallback} + />*/} + +
    + )} +
    + {!currentTimeHidden &&
    } + + + {shifts && shifts.length ? ( + shifts.map(({ shiftId, events }, index) => { + return ( + + + + ); + }) + ) : ( + + + + )} + +
    +
    + + ); + } + + getRotationClickHandler = (shiftId: Shift['id']) => { + const { onClick } = this.props; + + return () => { + onClick(shiftId); + }; + }; + + onSearchTermChangeCallback = () => {}; +} + +export default withMobXProviderContext(ScheduleFinal); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx new file mode 100644 index 00000000..253c4c46 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -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 { + 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 ( + <> +
    +
    + +
    Overrides
    + +
    +
    +
    + {!currentTimeHidden &&
    } + + + {shifts && shifts.length ? ( + shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( + + { + this.onRotationClick(shiftId, moment); + }} + transparent={isPreview} + /> + + )) + ) : ( + + { + this.onRotationClick('new', moment); + }} + /> + + )} + +
    + {/*
    + + Add override +
    */} +
    + {shiftIdToShowRotationForm && ( + { + 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); diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts index aad545f1..fe40a251 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts @@ -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 }, + }, + ], +}; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 0273cb68..2c58562e 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -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 ( { + 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) => {}; diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css new file mode 100644 index 00000000..ce26a72d --- /dev/null +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -0,0 +1,86 @@ +.root { + height: 28px; + background: #595959; + border-radius: 2px; + position: relative; + display: flex; + overflow: hidden; + margin: 0 1px; + padding: 4px; + align-items: center; +} + +.working-hours { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.stack { + display: flex; + flex-direction: column; + gap: 1px; + flex-shrink: 0; +} + +.root__type_gap { + background: rgba(209, 14, 92, 0.2); + border: 1px dashed #ff5286; + color: rgba(209, 14, 92, 0.5); + visibility: hidden; +} + +.root__inactive { + opacity: 0.5; +} + +.title { + z-index: 1; + color: #fff; + font-size: 12px; + font-weight: 500; + pointer-events: none; +} + +.label { + background: rgba(255, 255, 255, 0.7); + border-radius: 2px; + display: inline-block; + padding: 2px 4px; + line-height: 16px; + z-index: 1; + font-size: 10px; + font-weight: bold; + margin-right: 5px; + flex-shrink: 0; + pointer-events: none; +} + +.details { + width: auto; +} + +.details-user-status { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.details-user-status__type_success { + background-color: var(--success-text-color); +} + +.time { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: white; + z-index: 2; +} + +.is-oncall-icon { + color: var(--oncall-icon-stroke-color); + margin-left: -2px; +} diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx new file mode 100644 index 00000000..406bef15 --- /dev/null +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -0,0 +1,219 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; + +import Line from 'components/ScheduleUserDetails/img/line.svg'; +import Text from 'components/Text/Text'; +import WorkingHours from 'components/WorkingHours/WorkingHours'; +import { IsOncallIcon } from 'icons'; +import { Event, Schedule } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; + +import { getTitle } from './ScheduleSlot.helpers'; + +import styles from './ScheduleSlot.module.css'; + +interface ScheduleSlotProps { + event: Event; + scheduleId: Schedule['id']; + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + color?: string; + label?: string; +} + +const cx = cn.bind(styles); + +const ScheduleSlot: FC = observer((props) => { + const { event, scheduleId, startMoment, currentTimezone, color, label } = props; + const { users } = event; + + const trackMouse = false; + + const [mouseX, setMouseX] = useState(0); + + const start = dayjs(event.start); + const end = dayjs(event.end); + + const duration = end.diff(start, 'seconds'); + + const store = useStore(); + + const base = 60 * 60 * 24 * 7; + + const width = duration / base; + + const handleMouseMove = useCallback((event) => { + setMouseX(event.nativeEvent.offsetX); + }, []); + + const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now; + + return ( +
    + {event.is_gap ? ( + }> +
    + {trackMouse && mouseX > 0 &&
    } + {label &&
    {label}
    } +
    + + ) : event.is_empty ? ( +
    + {label && ( +
    + {label} +
    + )} +
    + ) : ( + users.map(({ pk: userPk }, userIndex) => { + const storeUser = store.userStore.items[userPk]; + + // TODO remove + if (!storeUser) { + store.userStore.updateItem(userPk); + } + + const inactive = false; + + const title = getTitle(storeUser); + + const isOncall = Boolean( + storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk) + ); + + return ( + + } + > +
    setMouseX(0) : undefined} + > + {trackMouse && mouseX > 0 &&
    } + {storeUser && ( + + )} + {userIndex === 0 && label && ( +
    + {label} +
    + )} +
    {title}
    +
    + + ); + }) + )} +
    + ); +}); + +export default ScheduleSlot; + +interface ScheduleSlotDetailsProps { + user: User; + isOncall: boolean; + currentTimezone: Timezone; + event: Event; +} + +const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { + const { user, currentTimezone, event, isOncall } = props; + + return ( +
    + + + + {isOncall && } + {user?.username} + + + + {/* + + 30 apr, 7:54 + */} + + + + {dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')} + + + + + + + {currentTimezone} + + {/* 30 apr, 12:54 */} + {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + + + +
    + ); +}; + +interface ScheduleGapDetailsProps { + currentTimezone: Timezone; + event: Event; +} + +const ScheduleGapDetails = (props: ScheduleGapDetailsProps) => { + const { currentTimezone, event } = props; + + return ( +
    + + + + {currentTimezone} + {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + + + {/*Gaps this week + + Number of gaps + 12 + + + Time + 23h 12m + */} + +
    + ); +}; diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css new file mode 100644 index 00000000..bedeb67b --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css @@ -0,0 +1,3 @@ +.root { + +} diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx new file mode 100644 index 00000000..d6358951 --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import { useStore } from 'state/useStore'; + +import styles from './SchedulesFilters.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesFiltersProps {} + +const SchedulesFilters = observer((props: SchedulesFiltersProps) => { + const {} = props; + + const store = useStore(); + + const {} = store; + + return
    ; +}); + +export default SchedulesFilters; diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 82e57fd6..89e0f3ce 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -134,7 +134,7 @@ export const TabsContent = observer((props: TabsContentProps) => { (store.hasFeature(AppFeature.CloudNotifications) ? ( ) : ( - + ))} {activeTab === UserSettingsTab.MobileAppVerification && ( diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.module.css index 1fd5ef88..6675616e 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.module.css +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.module.css @@ -1,8 +1,19 @@ -.input { - flex-grow: 1; +.switch { + display: flex; + flex-direction: row; + align-items: center; } -.telegram-code { - font-weight: bolder; - padding: 0 4px; +.switch__icon { + margin-right: 12px; +} + +.phone__field { + width: 100%; + margin-bottom: 8px; +} + +.phone__forgetHeading { + display: block; + margin-bottom: 24px; } diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx index 6b3f20a1..8d0f57cb 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx @@ -1,12 +1,11 @@ -import React, { HTMLAttributes, useCallback, useRef, useState } from 'react'; +import React, { HTMLAttributes, useCallback, useRef, useReducer } from 'react'; -import { Alert, Button, Field, HorizontalGroup, Icon, Input, Tooltip } from '@grafana/ui'; +import { Alert, Button, Field, HorizontalGroup, Icon, Input, Switch, Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import PluginLink from 'components/PluginLink/PluginLink'; -import WithConfirm from 'components/WithConfirm/WithConfirm'; -import userSettings from 'containers/UserSettings/UserSettings'; +import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; @@ -20,47 +19,80 @@ const cx = cn.bind(styles); interface PhoneVerificationProps extends HTMLAttributes { userPk?: User['pk']; - phone?: string; } +interface PhoneVerificationState { + phone: string; + code: string; + isCodeSent: boolean; + isPhoneNumberHidden: boolean; + isLoading: boolean; + showForgetScreen: boolean; +} + +const PHONE_REGEX = /^\+\d{8,15}$/; + const PhoneVerification = observer((props: PhoneVerificationProps) => { - const { phone: propsPhone, userPk: propsUserPk } = props; - const [phone, setPhone] = useState(propsPhone); - const [code, setCode] = useState(''); - const [codeSent, setCodeSent] = useState(false); - - const codeInputRef = useRef(); - - const onChangePhoneCallback = useCallback((event) => { - setCodeSent(false); - setPhone(event.target.value); - }, []); - - const onChangeCodeCallback = useCallback((event) => { - setCode(event.target.value); - }, []); + const { userPk: propsUserPk } = props; const store = useStore(); const { userStore, teamStore } = store; const userPk = (propsUserPk || userStore.currentUserPk) as User['pk']; - const user = userStore.items[userPk as User['pk']]; + let user = userStore.items[userPk]; + + const [{ showForgetScreen, phone, code, isCodeSent, isPhoneNumberHidden, isLoading }, setState] = useReducer( + (state: PhoneVerificationState, newState: Partial) => ({ + ...state, + ...newState, + }), + { + code: '', + phone: user.verified_phone_number || '+', + isLoading: false, + isCodeSent: false, + showForgetScreen: false, + isPhoneNumberHidden: user.hide_phone_number, + } + ); + + const codeInputRef = useRef(); + + const onTogglePhoneCallback = useCallback( + async ({ currentTarget: { checked: isPhoneNumberHidden } }: React.ChangeEvent) => { + setState({ isPhoneNumberHidden, isLoading: true }); + + await userStore.updateUser({ pk: userPk, hide_phone_number: isPhoneNumberHidden }); + user = userStore.items[userPk]; + + setState({ phone: user.verified_phone_number, isLoading: false }); + }, + [] + ); + + const onChangePhoneCallback = useCallback((event: React.ChangeEvent) => { + setState({ isCodeSent: false, phone: event.target.value }); + }, []); + + const onChangeCodeCallback = useCallback((event: React.ChangeEvent) => { + setState({ code: event.target.value }); + }, []); const handleMakeTestCallClick = useCallback(() => { userStore.makeTestCall(userPk); }, [userPk]); const handleForgetNumberClick = useCallback(() => { - userStore.forgetPhone(userPk).then(() => { - setPhone(''); - userStore.loadUser(userPk); + userStore.forgetPhone(userPk).then(async () => { + await userStore.loadUser(userPk); + setState({ phone: '', showForgetScreen: false, isCodeSent: false }); }); }, [userPk]); const { isTestCallInProgress } = userStore; const onSubmitCallback = useCallback(async () => { - if (codeSent) { + if (isCodeSent) { userStore .verifyPhone(userPk, code) .then(() => { @@ -75,10 +107,11 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { email: user.email, unverified_phone_number: phone, }); + userStore .fetchVerificationCode(userPk) .then(() => { - setCodeSent(true); + setState({ isCodeSent: true }); if (codeInputRef.current) { codeInputRef.current.focus(); @@ -90,26 +123,43 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { ); }); } - }, [code, codeSent, phone, store, user.email, userPk, userStore]); + }, [code, isCodeSent, phone, store, user.email, userPk, userStore]); - const isPhoneInvalid = !phone || !/^\+\d{8,15}$/.test(phone); + const isTwilioConfigured = teamStore.currentTeam?.env_status.twilio_configured; + const phoneHasMinimumLength = phone?.length > 8; - const twilioConfigured = teamStore.currentTeam?.env_status.twilio_configured; + const isPhoneValid = phoneHasMinimumLength && PHONE_REGEX.test(phone); + const showPhoneInputError = phoneHasMinimumLength && !isPhoneValid && !isPhoneNumberHidden && !isLoading; - const showPhoneInputError = phone && phone.length > 8 && isPhoneInvalid; const isCurrent = userStore.currentUserPk === user.pk; const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; - const isButtonDisabled = phone === user.verified_phone_number || (!codeSent && isPhoneInvalid) || !twilioConfigured; + const isButtonDisabled = + phone === user.verified_phone_number || (!isCodeSent && !isPhoneValid) || !isTwilioConfigured; + + const isPhoneDisabled = !!user.verified_phone_number; + const isCodeFieldDisabled = !isCodeSent || !store.isUserActionAllowed(action); + const showToggle = user.verified_phone_number && user.pk === userStore.currentUserPk; + + if (showForgetScreen) { + return ( + setState({ showForgetScreen: false })} + onForget={handleForgetNumberClick} + /> + ); + } return ( <> - {user.verified_phone_number && ( + {isPhoneValid && !user.verified_phone_number && ( <>
    )} - {!twilioConfigured && store.hasFeature(AppFeature.LiveSettings) && ( + + {!isTwilioConfigured && store.hasFeature(AppFeature.LiveSettings) && ( <> {
    )} - - + + + + + } + value={phone} + onChange={onChangePhoneCallback} + /> + + + + {!user.verified_phone_number && ( } - value={phone} - onChange={onChangePhoneCallback} + ref={codeInputRef} + disabled={isCodeFieldDisabled} + autoFocus={isCodeSent} + onChange={onChangeCodeCallback} + placeholder="Please enter the code" + className={cx('phone__field')} /> - - - + )} + + {showToggle && ( +
    +
    + +
    + +
    + )} + +
    - + + setState({ showForgetScreen: true })} + user={user} + /> + + ); +}); + +interface ForgetPhoneScreenProps { + phone: string; + onCancel(): void; + onForget(): void; +} + +function ForgetPhoneScreen({ phone, onCancel, onForget }: ForgetPhoneScreenProps) { + return ( + <> + + Do you really want to forget the verified phone number {phone} ? + + + + + + + ); +} + +interface PhoneVerificationButtonsGroupProps { + action: UserAction.UpdateOwnSettings | UserAction.UpdateOtherUsersSettings; + + isCodeSent: boolean; + isButtonDisabled: boolean; + isTestCallInProgress: boolean; + isTwilioConfigured: boolean; + + onSubmitCallback(): void; + handleMakeTestCallClick(): void; + onShowForgetScreen(): void; + + user: User; +} + +function PhoneVerificationButtonsGroup({ + action, + isCodeSent, + isButtonDisabled, + isTestCallInProgress, + isTwilioConfigured, + onSubmitCallback, + handleMakeTestCallClick, + onShowForgetScreen, + user, +}: PhoneVerificationButtonsGroupProps) { + const showForgetNumber = !!user.verified_phone_number; + const showVerifyOrSendCodeButton = !user.verified_phone_number; + + return ( + + {showVerifyOrSendCodeButton && ( - - - - - + )} + + {showForgetNumber && ( + + )} + + {user.verified_phone_number && ( + + - - - - - + )} + + + + + ); -}); +} export default PhoneVerification; diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css new file mode 100644 index 00000000..0eab0c54 --- /dev/null +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css @@ -0,0 +1,136 @@ +.root { + border: var(--border-medium); + display: flex; + flex-direction: column; + border-radius: 2px; + background: var(--primary-background); +} + +.header { + padding: 0 10px; +} + +.title { + font-weight: 500; + font-size: 19px; + line-height: 24px; + color: rgba(204, 204, 220, 0.65); + margin: 16px 0; +} + +.current-time { + position: absolute; + left: 0; + width: 1px; + background: #fff; + top: 0; + bottom: 0; + z-index: 0; +} + +@-webkit-keyframes run { + 0% { + left: 0; + } + + 100% { + left: 100%; + } +} + +.users { + position: relative; + height: 76px; +} + +.avatar-group { + position: absolute; + top: 10px; + height: 32px; +} + +.avatar { + position: absolute; + top: 0; + transition: opacity 200ms ease, left 200ms ease; + border-radius: 50%; +} + +.is-oncall-icon { + color: var(--oncall-icon-stroke-color); + position: absolute; + left: -1px; + bottom: -1px; +} + +.user-more { + position: absolute; + padding: 0 5px; + bottom: 0; + font-size: 12px; + line-height: 16px; + background: #454952; + border-radius: 8px; + text-align: center; + transition: opacity 200ms ease, left 200ms ease; + pointer-events: none; +} + +.avatar-group_inactive { + pointer-events: none; + opacity: 0.2; + transition: opacity 0.5s ease; +} + +.time-stripe { + position: relative; + height: 4px; + + --color: rgba(61, 113, 217, 0.2); + + background: + repeating-linear-gradient( + -45deg, + var(--color), + var(--color) 4px, + transparent 4px, + transparent 8px + ); +} + +.current-user-stripe { + position: absolute; + top: 0; + bottom: 0; + height: 4px; + background: #3d71d9; + border-radius: 2px; + left: calc((9 / 24) * 100%); + right: calc((7 / 24) * 100%); +} + +.time-marks { + position: absolute; + top: -24px; + display: flex; + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(204, 204, 220, 0.65); + width: 100%; +} + +.time-mark-text { + display: inline-block; + padding: 0 5px; +} + +.time-mark-text__translated { + transform: translate(-50%, 0); + padding: 0; +} + +.time-mark:last-child { + position: absolute; + right: 0; +} diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx new file mode 100644 index 00000000..497d4fd4 --- /dev/null +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -0,0 +1,280 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { HorizontalGroup, InlineSwitch, Tooltip } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; + +import Avatar from 'components/Avatar/Avatar'; +import ScheduleUserDetails from 'components/ScheduleUserDetails/ScheduleUserDetails'; +import Text from 'components/Text/Text'; +import { IsOncallIcon } from 'icons'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; + +import styles from './UsersTimezones.module.css'; + +interface UsersTimezonesProps { + userIds: Array; + tz: Timezone; + onTzChange: (tz: Timezone) => void; + onCallNow: Array>; +} + +const cx = cn.bind(styles); + +const hoursToSplit = 3; + +const jLimit = 24 / hoursToSplit; + +const UsersTimezones: FC = (props) => { + const { userIds, tz, onTzChange, onCallNow } = props; + + const store = useStore(); + + useEffect(() => { + userIds.forEach((userId) => { + if (!store.userStore.items[userId]) { + store.userStore.updateItem(userId); + } + }); + }, [userIds]); + + const users = useMemo( + () => userIds.map((userId) => store.userStore.items[userId]).filter(Boolean), + [userIds, store.userStore.items] + ); + + const currentMoment = useMemo(() => dayjs().tz(tz), [tz]); + + const currentTimeX = useMemo(() => { + const midnight = dayjs().tz(tz).startOf('day'); + const diff = currentMoment.diff(midnight, 'minutes'); + + return (diff / 1440) * 100; + }, [currentMoment, tz]); + + const momentsToRender = useMemo(() => { + const momentsToRender = []; + + const d = dayjs().utc().startOf('day'); + + for (let j = 0; j < jLimit; j++) { + const m = dayjs(d).add(j * hoursToSplit, 'hour'); + momentsToRender.push(m); + } + return momentsToRender; + }, []); + + return ( +
    +
    + + +
    Schedule team and timezones
    + {/* + + Current schedule users only + */} +
    +
    + + Current timezone: {tz}, local time: {currentMoment.format('HH:mm')} + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + {momentsToRender.map((mm, index) => ( +
    + 0, + })} + > + {mm.format('HH:mm')} + +
    + ))} +
    + 24:00 +
    +
    +
    +
    + ); +}; + +interface UserAvatarsProps { + users: User[]; + currentMoment: dayjs.Dayjs; + onTzChange: (timezone: Timezone) => void; + onCallNow: Array>; +} + +const UserAvatars = (props: UserAvatarsProps) => { + const { users, currentMoment, onTzChange, onCallNow } = props; + const userGroups = useMemo(() => { + return users + .reduce((memo, user) => { + const userUtcOffset = dayjs().tz(user.timezone).utcOffset(); + let group = memo.find((group) => group.utcOffset === userUtcOffset); + if (!group) { + group = { utcOffset: userUtcOffset, users: [] }; + memo.push(group); + } + group.users.push(user); + + return memo; + }, []) + .sort((a, b) => { + if (a.utcOffset > b.utcOffset) { + return 1; + } + if (a.utcOffset < b.utcOffset) { + return -1; + } + + return 0; + }); + }, [users]); + + const [activeUtcOffset, setActiveUtcOffset] = useState(undefined); + + return ( +
    + {userGroups.map((group) => { + const userCurrentMoment = dayjs(currentMoment).tz(group.users[0].timezone); // TODO try using group.utcOffset + const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes'); + + const xPos = (diff / (60 * 24)) * 100; + + return ( + + ); + })} +
    + ); +}; + +interface AvatarGroupProps { + users: User[]; + xPos: number; + currentMoment: dayjs.Dayjs; + utcOffset: number; + onSetActiveUtcOffset: (utcOffset: number | undefined) => void; + activeUtcOffset: number; + onTzChange: (timezone: Timezone) => void; + onCallNow: Array>; +} + +const LIMIT = 3; +const AVATAR_WIDTH = 32; +const AVATAR_GAP = 5; + +const AvatarGroup = (props: AvatarGroupProps) => { + const { + users: propsUsers, + currentMoment, + xPos, + onTzChange, + utcOffset, + onSetActiveUtcOffset, + activeUtcOffset, + onCallNow, + } = props; + + const active = !isNaN(activeUtcOffset) && activeUtcOffset === utcOffset; + + const translateLeft = -AVATAR_WIDTH / 2; + + const users = useMemo(() => { + return [...propsUsers].sort((a, b) => { + const aIsOncall = Number(onCallNow.some((onCallUser) => a.pk === onCallUser.pk)); + const bIsOncall = Number(onCallNow.some((onCallUser) => b.pk === onCallUser.pk)); + + if (aIsOncall < bIsOncall) { + return 1; + } + if (aIsOncall > bIsOncall) { + return -1; + } + + return 0; + }); + }, [propsUsers]); + + const getAvatarClickHandler = useCallback((timezone: Timezone) => { + return () => { + onTzChange(timezone); + }; + }, []); + + const width = active ? users.length * AVATAR_WIDTH + (users.length - 1) * AVATAR_GAP : AVATAR_WIDTH; + + return ( +
    onSetActiveUtcOffset(utcOffset)} + onMouseLeave={() => onSetActiveUtcOffset(undefined)} + > + {users.map((user, index, array) => { + const isOncall = onCallNow.some((onCallUser) => user.pk === onCallUser.pk); + + return ( + } + > +
    = LIMIT ? 'hidden' : 'visible', + zIndex: array.length - index - 1, + /* opacity: userHour >= 9 && userHour < 18 ? 1 : 0.5,*/ + }} + onClick={getAvatarClickHandler(user.timezone)} + > + + {isOncall && } +
    +
    + ); + })} +
    LIMIT ? '1' : '0', + zIndex: users.length, + left: active ? `${users.length * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${LIMIT * 10}px`, + }} + className={cx('user-more')} + > + +{users.length - LIMIT} +
    +
    + ); +}; + +export default UsersTimezones; diff --git a/grafana-plugin/src/dummy/dummy.ts b/grafana-plugin/src/dummy/dummy.ts new file mode 100644 index 00000000..e2e7845b --- /dev/null +++ b/grafana-plugin/src/dummy/dummy.ts @@ -0,0 +1 @@ +interface Dummy {} diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index eb4f543c..ffc6f75c 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -232,3 +232,40 @@ export const GrafanaIcon = (props: IconProps) => ( ); + +export const ExpandIcon = (props: IconProps) => { + return ( + + + + ); +}; + +interface IsOncallIconProps { + className: string; +} + +export const IsOncallIcon = (props: IsOncallIconProps) => { + const { className } = props; + + return ( + + + + + ); +}; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 8bc67fca..45344f60 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -6,8 +6,7 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { showApiError, refreshPageError } from 'utils'; -import { openErrorNotification } from 'utils'; +import { showApiError, refreshPageError, openErrorNotification } from 'utils'; import { Alert, AlertAction, IncidentStatus } from './alertgroup.types'; diff --git a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts index 2ab202a8..55842020 100644 --- a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts +++ b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts @@ -1,5 +1,6 @@ export interface EscalationChain { id: string; + pk: string; //? because GET related_escalation_chains returns {name,pk}[] is_default: boolean; name: string; number_of_integrations: number; diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts new file mode 100644 index 00000000..d626d32e --- /dev/null +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -0,0 +1,191 @@ +import dayjs from 'dayjs'; + +import { Event, Layer, ScheduleType, Shift } from './schedule.types'; + +export const getFromString = (moment: dayjs.Dayjs) => { + return moment.format('YYYY-MM-DD'); +}; + +export const fillGaps = (events: Event[]) => { + const newEvents = []; + + for (const [i, event] of events.entries()) { + newEvents.push(event); + + const nextEvent = events[i + 1]; + + if (nextEvent) { + if (nextEvent.start !== event.end) { + newEvents.push({ + start: event.end, + end: nextEvent.start, + is_gap: true, + users: [], + all_day: false, + shift: null, + missing_users: [], + is_empty: true, + calendar_type: ScheduleType.API, + priority_level: null, + source: 'web', + }); + } + } + } + + return newEvents; +}; + +export const splitToShiftsAndFillGaps = (events: Event[]) => { + const shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> = []; + + for (const [i, event] of events.entries()) { + if (event.shift?.pk) { + let shift = shifts.find((shift) => shift.shiftId === event.shift?.pk); + if (!shift) { + shift = { shiftId: event.shift.pk, priority: event.priority_level, events: [] }; + shifts.push(shift); + } + shift.events.push(event); + } + } + + shifts.forEach((shift) => { + shift.events = fillGaps(shift.events); + }); + + return shifts; +}; + +export const splitToLayers = ( + shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> +) => { + return shifts + .reduce((memo, shift) => { + let layer = memo.find((level) => level.priority === shift.priority); + if (!layer) { + layer = { priority: shift.priority, shifts: [] }; + memo.push(layer); + } + layer.shifts.push(shift); + + return memo; + }, []) + .sort((a, b) => { + if (a.priority > b.priority) { + return 1; + } + if (a.priority < b.priority) { + return -1; + } + + return 0; + }); +}; + +export const enrichLayers = ( + layers: Layer[], + newEvents: Event[], + shiftId: Shift['id'] | 'new', + priority: Shift['priority_level'] +) => { + let shiftIdFromEvent = shiftId; + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftIdFromEvent = event.shift.pk; + } + } + + const updatingLayer = { + priority, + shifts: [ + { + shiftId: shiftIdFromEvent, + isPreview: true, + events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)), + }, + ], + }; + + let added = false; + layers = layers.reduce((memo, layer, index) => { + if (shiftId === 'new') { + if (layer.priority === priority) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts, ...updatingLayer.shifts]; + + memo[index] = newLayer; + + added = true; + } + } else { + const oldShiftIndex = layer.shifts.findIndex((shift) => shift.shiftId === updatingLayer.shifts[0].shiftId); + if (oldShiftIndex > -1) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts]; + newLayer.shifts[oldShiftIndex] = updatingLayer.shifts[0]; + + memo[index] = newLayer; + + added = true; + } + } + + return layers; + }, layers); + + if (!added) { + layers.push(updatingLayer); + } + + return layers; +}; + +export const enrichOverrides = ( + overrides: Array<{ shiftId: Shift['id']; events: Event[] }>, + newEvents: Event[], + shiftId: Shift['id'] +) => { + let shiftIdFromEvent = shiftId; + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftIdFromEvent = event.shift.pk; + } + } + + const newShift = { shiftId: shiftIdFromEvent, isPreview: true, events: fillGaps(newEvents) }; + + const index = overrides.findIndex((shift) => shift.shiftId === shiftId); + + if (index > -1) { + overrides[index] = newShift; + } else { + overrides.push(newShift); + } + + return overrides; +}; + +const L1_COLORS = ['#3D71D9', '#6D609C', '#4D3B72', '#8214A0']; + +const L2_COLORS = ['#3CB979', '#188343', '#84362A', '#521913']; + +const L3_COLORS = ['#377277', '#638282', '#364E4E', '#423220']; + +const OVERRIDE_COLORS = ['#C69B06', '#C2C837']; + +const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS]; + +export const getColor = (layerIndex: number, rotationIndex: number) => { + const normalizedLayerIndex = layerIndex % COLORS.length; + const normalizedRotationIndex = rotationIndex % COLORS[normalizedLayerIndex]?.length; + + return COLORS[normalizedLayerIndex]?.[normalizedRotationIndex]; +}; + +export const getOverrideColor = (rotationIndex: number) => { + const normalizedRotationIndex = rotationIndex % OVERRIDE_COLORS.length; + return OVERRIDE_COLORS[normalizedRotationIndex]; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 3cf0e8d1..fbb1e533 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -1,11 +1,31 @@ -import { omit } from 'lodash-es'; +import { SelectOptions } from '@grafana/ui'; +import dayjs from 'dayjs'; +import { omit, reject } from 'lodash-es'; import { action, observable, toJS } from 'mobx'; +import ReactCSSTransitionGroup from 'react-transition-group'; // ES6 import BaseStore from 'models/base_store'; +import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; +import { SlackChannel } from 'models/slack_channel/slack_channel.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; +import { SelectOption } from 'state/types'; -import { Schedule, ScheduleEvent } from './schedule.types'; +import { + enrichLayers, + enrichOverrides, + fillGaps, + getFromString, + splitToLayers, + splitToShiftsAndFillGaps, +} from './schedule.helpers'; +import { Events, Rotation, RotationType, Schedule, ScheduleEvent, Shift, Event, Layer } from './schedule.types'; + +const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +let I = 0; export class ScheduleStore extends BaseStore { @observable @@ -14,11 +34,48 @@ export class ScheduleStore extends BaseStore { @observable.shallow items: { [id: string]: Schedule } = {}; + @observable.shallow + shifts: { [id: string]: Shift } = {}; + + @observable.shallow + relatedEscalationChains: { [id: string]: EscalationChain[] } = {}; + + @observable.shallow + relatedUsers: { [id: string]: { [key: string]: Event } } = {}; + + @observable.shallow + rotations: { + [id: string]: { + [startMoment: string]: Rotation; + }; + } = {}; + + @observable.shallow + events: { + [scheduleId: string]: { + [type: string]: { + [startMoment: string]: Array<{ shiftId: string; events: Event[]; isPreview?: boolean }> | Layer[]; + }; + }; + } = {}; + + @observable + finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + + @observable + rotationPreview?: Layer[]; + + @observable + overridePreview?: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; + @observable scheduleToScheduleEvents: { [id: string]: ScheduleEvent[]; } = {}; + @observable + byDayOptions: SelectOption[]; + constructor(rootStore: RootStore) { super(rootStore); @@ -57,7 +114,7 @@ export class ScheduleStore extends BaseStore { @action async updateItems(query = '') { - const result = await this.getAll(); + const result = await makeRequest(this.path, { method: 'GET', params: { search: query } }); this.items = { ...this.items, @@ -119,4 +176,202 @@ export class ScheduleStore extends BaseStore { method: 'DELETE', }); } + + // ------- NEW SCHEDULES API ENDPOINTS --------- + + async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: Partial) { + const type = isOverride ? 3 : 2; + + const response = await makeRequest(`/oncall_shifts/`, { + data: { type, schedule: scheduleId, ...params }, + method: 'POST', + }).catch(this.onApiError); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; + } + + async updateRotationPreview( + scheduleId: Schedule['id'], + shiftId: Shift['id'] | 'new', + fromString: string, + isOverride: boolean, + params: Partial + ) { + const type = isOverride ? 3 : 2; + + const response = await makeRequest(`/oncall_shifts/preview/`, { + params: { date: fromString }, + data: { type, schedule: scheduleId, shift_pk: shiftId === 'new' ? undefined : shiftId, ...params }, + method: 'POST', + }).catch(this.onApiError); + + if (isOverride) { + this.overridePreview = enrichOverrides( + [...(this.events[scheduleId]?.['override']?.[fromString] as Array<{ shiftId: string; events: Event[] }>)], + response.rotation, + shiftId + ); + } else { + const layers = enrichLayers( + [...(this.events[scheduleId]?.['rotation']?.[fromString] as Layer[])], + response.rotation, + shiftId, + params.priority_level + ); + + this.rotationPreview = layers; + } + + this.finalPreview = splitToShiftsAndFillGaps(response.final); /*.filter((shift) => shift.shiftId !== shiftId);*/ + } + + @action + clearPreview() { + this.finalPreview = undefined; + this.rotationPreview = undefined; + this.overridePreview = undefined; + } + + async updateRotation(shiftId: Shift['id'], params: Partial) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, { + data: { ...params }, + method: 'PUT', + }).catch(this.onApiError); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; + } + + updateRelatedEscalationChains = async (id: Schedule['id']) => { + const response = await makeRequest(`/schedules/${id}/related_escalation_chains`, { + method: 'GET', + }); + + this.relatedEscalationChains = { + ...this.relatedEscalationChains, + [id]: response, + }; + + return response; + }; + + updateRelatedUsers = async (id: Schedule['id']) => { + const { users } = await makeRequest(`/schedules/${id}/next_shifts_per_user`, { + method: 'GET', + }); + + this.relatedUsers = { + ...this.relatedUsers, + [id]: users, + }; + + return users; + }; + + async updateOncallShifts(scheduleId: Schedule['id']) { + const { results } = await makeRequest(`/oncall_shifts/`, { + params: { + schedule_id: scheduleId, + }, + method: 'GET', + }); + + this.shifts = { + ...this.shifts, + ...results.reduce( + (acc: { [key: number]: Shift }, item: Shift) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + } + + @action + async updateOncallShift(shiftId: Shift['id']) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, {}); + + this.shifts = { + ...this.shifts, + [shiftId]: response, + }; + + return response; + } + + async deleteOncallShift(shiftId: Shift['id']) { + return await makeRequest(`/oncall_shifts/${shiftId}`, { + method: 'DELETE', + }).catch(this.onApiError); + } + + async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) { + const dayBefore = startMoment.subtract(1, 'day'); + + const response = await makeRequest(`/schedules/${scheduleId}/filter_events/`, { + params: { + type, + date: getFromString(dayBefore), + days, + }, + method: 'GET', + }); + + const fromString = getFromString(startMoment); + + const shifts = splitToShiftsAndFillGaps(response.events); + + // merge users on frontend side, we don't need it now + /*shifts.forEach((shift) => { + for (let i = 0; i < shift.events.length; i++) { + const iEvent = shift.events[i]; + + for (let j = i + 1; j < shift.events.length; j++) { + const jEvent = shift.events[j]; + if (iEvent.start === jEvent.start && iEvent.end === jEvent.end) { + iEvent.users.push(...jEvent.users); + jEvent.merged = true; + } + } + shift.events = shift.events.filter((event) => !event.merged); + } + });*/ + + const layers = type === 'rotation' ? splitToLayers(shifts) : undefined; + + this.events = { + ...this.events, + [scheduleId]: { + ...this.events[scheduleId], + [type]: { + ...this.events[scheduleId]?.[type], + [fromString]: layers ? layers : shifts, + }, + }, + }; + + // console.log(toJS(this.events)); + } + + async updateFrequencyOptions() { + return await makeRequest(`/oncall_shifts/frequency_options/`, { + method: 'GET', + }); + } + + async updateDaysOptions() { + this.byDayOptions = await makeRequest(`/oncall_shifts/days_options/`, { + method: 'GET', + }); + } } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index dc3cc3f7..ff36079e 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,7 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { 'Calendar', 'Ical', - 'Web', + 'API', } export interface Schedule { @@ -25,6 +25,7 @@ export interface Schedule { mention_oncall_next: boolean; mention_oncall_start: boolean; notify_empty_oncall: number; + number_of_escalation_chains: number; } export interface ScheduleEvent { @@ -44,3 +45,53 @@ export interface CreateScheduleExportTokenResponse { created_at: string; export_url: string; } + +export interface Shift { + by_day: string[]; + frequency: number | null; + id: string; + interval: number; + priority_level: number; + rolling_users: Array>; + rotation_start: string; + schedule: Schedule['id']; + shift_end: string; + shift_start: string; + title: string; + type: number; // 2 - rotations, 3 - overrides + until: string | null; + updated_shift: null; +} + +export interface Rotation { + id: string; + shifts: Shift[]; +} + +export type RotationType = 'final' | 'rotation' | 'override'; + +export interface Event { + all_day: boolean; + calendar_type: ScheduleType; + end: string; + is_empty: boolean; + is_gap: boolean; + missing_users: Array<{ display_name: User['username']; pk: User['pk'] }>; + priority_level: number; + shift: { pk: Shift['id'] | null }; + source: string; + start: string; + users: Array<{ display_name: User['username']; pk: User['pk'] }>; +} + +export interface Events { + events: Event[]; + id: string; + name: string; + type: number; //? +} + +export interface Layer { + priority: Shift['priority_level']; + shifts: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; +} diff --git a/grafana-plugin/src/models/timezone/timezone.helpers.ts b/grafana-plugin/src/models/timezone/timezone.helpers.ts new file mode 100644 index 00000000..688b409b --- /dev/null +++ b/grafana-plugin/src/models/timezone/timezone.helpers.ts @@ -0,0 +1,609 @@ +import dayjs from 'dayjs'; + +const tzs = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Knox_IN', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Louisville', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Mendoza', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +]; + +export const getRandomTimezone = () => { + return tzs[Math.floor(Math.random() * tzs.length)]; +}; + +export const getTzOffsetString = (moment: dayjs.Dayjs) => { + const userOffset = moment.utcOffset(); + const userOffsetHours = userOffset / 60; + const userOffsetHoursStr = + userOffsetHours > 0 ? `+${userOffsetHours} GMT` : userOffset < 0 ? `${userOffsetHours} GMT` : `GMT`; + + return userOffsetHoursStr; +}; diff --git a/grafana-plugin/src/models/timezone/timezone.types.ts b/grafana-plugin/src/models/timezone/timezone.types.ts new file mode 100644 index 00000000..0e330ef9 --- /dev/null +++ b/grafana-plugin/src/models/timezone/timezone.types.ts @@ -0,0 +1,598 @@ +import { concat } from 'lodash-es'; + +const tzs = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Knox_IN', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Louisville', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Mendoza', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +] as const; + +export type Timezone = typeof tzs[number]; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index bbee6eac..7af6f17c 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { Tooltip } from '@grafana/ui'; +import dayjs from 'dayjs'; import { pick } from 'lodash-es'; +import { Timezone } from 'models/timezone/timezone.types'; + import { User, UserRole } from './user.types'; export const getIconType = (role: UserRole) => { @@ -31,6 +34,10 @@ export const getRole = (role: UserRole) => { } }; +export const getTimezone = (user: User) => { + return user.timezone || 'UTC'; +}; + export const getUserNotificationsSummary = (user: User) => { if (!user) { return null; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 22dbd1f7..0570ee98 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -1,14 +1,17 @@ +import dayjs from 'dayjs'; import { get } from 'lodash-es'; import { action, computed, observable } from 'mobx'; +import moment from 'moment-timezone'; import BaseStore from 'models/base_store'; import { NotificationPolicyType } from 'models/notification_policy'; +import { getRandomTimezone } from 'models/timezone/timezone.helpers'; import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { move } from 'state/helpers'; -import { prepareForUpdate } from './user.helpers'; +import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; export class UserStore extends BaseStore { @@ -49,14 +52,24 @@ export class UserStore extends BaseStore { @action async loadCurrentUser() { - const user = await makeRequest('/user/', {}); + const response = await makeRequest('/user/', {}); + + let timezone; + if (!response.timezone) { + timezone = dayjs.tz.guess(); + this.update(response.pk, { timezone }); + } + + timezone = timezone || getTimezone(response); this.items = { ...this.items, - [user.pk]: user, + [response.pk]: { ...response, timezone }, }; - this.currentUserPk = user.pk; + this.currentUserPk = response.pk; + + // this.rootStore.currentTimezone = timezone; } @action @@ -65,7 +78,7 @@ export class UserStore extends BaseStore { this.items = { ...this.items, - [user.pk]: user, + [user.pk]: { ...user, timezone: getTimezone(user) }, }; } @@ -97,7 +110,10 @@ export class UserStore extends BaseStore { ...results.reduce( (acc: { [key: number]: User }, item: User) => ({ ...acc, - [item.pk]: item, + [item.pk]: { + ...item, + timezone: getTimezone(item), + }, }), {} ), diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 7bb09e0d..4d48e3d8 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -1,4 +1,5 @@ import { Team } from 'models/team/team.types'; +import { Timezone } from 'models/timezone/timezone.types'; import { UserAction } from 'state/userAction'; export enum UserRole { @@ -15,6 +16,7 @@ export interface User { avatar: string; name: string; company: string; + hide_phone_number: boolean; role_in_company: string; username: string; slack_id: string; @@ -54,4 +56,6 @@ export interface User { link?: string; cloud_connection_status?: number; hidden_fields?: boolean; + timezone: Timezone; + working_hours: { [key: string]: [] }; } diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index d24f2f30..228b82fa 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -318,7 +318,7 @@ class Incidents extends React.Component }, { width: '15%', - title: 'Source', + title: 'Integrations', key: 'source', render: withSkeleton(this.renderSource), }, diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index 5df8fde2..15561d51 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -13,7 +13,9 @@ import MaintenancePage2 from 'pages/maintenance/Maintenance'; import MigrationTool from 'pages/migration-tool/MigrationTool'; import OrganizationLogPage2 from 'pages/organization-logs/OrganizationLog'; import OutgoingWebhooks2 from 'pages/outgoing_webhooks/OutgoingWebhooks'; +import SchedulePage from 'pages/schedule/Schedule'; import SchedulesPage2 from 'pages/schedules/Schedules'; +import SchedulesPage from 'pages/schedules_NEW/Schedules'; import SettingsPage2 from 'pages/settings/SettingsPage'; import Test from 'pages/test/Test'; import UsersPage2 from 'pages/users/Users'; @@ -65,6 +67,19 @@ export const pages: PageDefinition[] = [ id: 'schedules', text: 'Schedules', }, + { + component: SchedulesPage, + icon: 'calendar-alt', + id: 'schedules-new', + text: 'Schedules α', + }, + { + component: SchedulePage, + icon: 'calendar-alt', + id: 'schedule', + text: 'Schedule', + hideFromTabs: true, + }, { component: ChatOpsPage, icon: 'comments-alt', diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts new file mode 100644 index 00000000..94e967eb --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -0,0 +1,16 @@ +import { DateTime, dateTime } from '@grafana/data'; +import dayjs from 'dayjs'; + +import { Timezone } from 'models/timezone/timezone.types'; + +export const getStartOfWeek = (tz: Timezone) => { + return dayjs().tz(tz).utcOffset() === 0 ? dayjs().utc().startOf('isoWeek') : dayjs().tz(tz).startOf('isoWeek'); +}; + +export const getUTCString = (moment: dayjs.Dayjs) => { + return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); +}; + +export const getDateTime = (date: string) => { + return dayjs(date); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css new file mode 100644 index 00000000..2e5746bd --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -0,0 +1,35 @@ + +.root { + max-width: 1600px; + margin: 0 auto; + margin-top: 24px; + + --rotations-border: var(--border-medium); + --rotations-background: var(--primary-background); +} + +.header { + position: sticky; /* TODO check */ + width: 100%; +} + +.desc { + width: 736px; +} + +.users-timezones { + width: 100%; + margin-bottom: 16px; +} + +.controls { + width: 100%; +} + +.rotations { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + width: 100%; +} diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx new file mode 100644 index 00000000..bff532ae --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -0,0 +1,409 @@ +import React, { useMemo } from 'react'; + +import { AppRootProps } from '@grafana/data'; +import { getLocationSrv } from '@grafana/runtime'; +import { + Button, + HorizontalGroup, + VerticalGroup, + RadioButtonGroup, + IconButton, + ToolbarButton, + Icon, + Field, +} from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; +import Draggable from 'react-draggable'; + +// import Rotations from 'components/Rotations/Rotations'; +import PluginLink from 'components/PluginLink/PluginLink'; +import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; +import ScheduleQuality from 'components/ScheduleQuality/ScheduleQuality'; +import Text from 'components/Text/Text'; +// import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; +import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import Rotations from 'containers/Rotations/Rotations'; +import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; +import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides'; +import UsersTimezones from 'containers/UsersTimezones/UsersTimezones'; +import { Shift } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import { getStartOfWeek, getUTCString } from './Schedule.helpers'; + +import styles from './Schedule.module.css'; + +const cx = cn.bind(styles); + +interface SchedulePageProps extends AppRootProps, WithStoreProps {} + +interface SchedulePageState { + startMoment: dayjs.Dayjs; + schedulePeriodType: string; + renderType: string; + shiftIdToShowRotationForm?: Shift['id']; + shiftIdToShowOverridesForm?: Shift['id']; +} + +const INITIAL_TIMEZONE = 'UTC'; // todo check why doesn't work + +@observer +class SchedulePage extends React.Component { + constructor(props: SchedulePageProps) { + super(props); + + const { store } = this.props; + this.state = { + startMoment: getStartOfWeek(store.currentTimezone), + schedulePeriodType: 'week', + renderType: 'timeline', + shiftIdToShowRotationForm: undefined, + shiftIdToShowOverridesForm: undefined, + }; + } + + async componentDidMount() { + const { store } = this.props; + const { startMoment } = this.state; + + /*if (!store.hasFeature(AppFeature.WebSchedules)) { + getLocationSrv().update({ query: { page: 'schedules' } }); + }*/ + + store.userStore.updateItems(); + + const { + query: { id }, + } = this.props; + + store.scheduleStore.updateFrequencyOptions(); + store.scheduleStore.updateDaysOptions(); + await store.scheduleStore.updateOncallShifts(id); // TODO we should know shifts to render Rotations + + this.updateEvents(); + } + + componentWillUnmount() { + const { store } = this.props; + + store.scheduleStore.clearPreview(); + } + + render() { + const { store } = this.props; + const { startMoment, schedulePeriodType, renderType, shiftIdToShowRotationForm, shiftIdToShowOverridesForm } = + this.state; + const { query } = this.props; + const { id: scheduleId } = query; + + const users = store.userStore.getSearchResult().results; + + const { scheduleStore, currentTimezone } = store; + + const schedule = scheduleStore.items[scheduleId]; + + return ( +
    + +
    + + + + + + + {schedule?.name} + + {/* + Grafana 1 +
    + Grafana 2 +
    + Grafana 3 + + } + /> + */} +
    + + {users && ( + + Current timezone: + + + )} + {/**/} + {/* + + + */} + + + + +
    +
    + + On-call Schedules. Use this to distribute notifications among team members you specified in the "Notify + Users from on-call schedule" step in escalation chains. + +
    + +
    +
    + + + + + + + +
    + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} +
    +
    + {/* + + + */} +
    +
    + {/*
    */} +
    + + + +
    + +
    + ); + } + + handleShowForm = async (shiftId: Shift['id'] | 'new') => { + const { + store: { scheduleStore }, + } = this.props; + + const shift = await scheduleStore.updateOncallShift(shiftId); + + if (shift.type === 2) { + this.setState({ shiftIdToShowRotationForm: shiftId }); + } else if (shift.type === 3) { + this.setState({ shiftIdToShowOverridesForm: shiftId }); + } + }; + + handleShowRotationForm = (shiftId: Shift['id'] | 'new') => { + this.setState({ shiftIdToShowRotationForm: shiftId }); + }; + + handleShowOverridesForm = (shiftId: Shift['id'] | 'new') => { + this.setState({ shiftIdToShowOverridesForm: shiftId }); + }; + + handleNameChange = (value: string) => { + const { store, query } = this.props; + const { id: scheduleId } = query; + + const schedule = store.scheduleStore.items[scheduleId]; + + store.scheduleStore + .update(scheduleId, { type: schedule.type, name: value }) + .then(() => store.scheduleStore.updateItem(scheduleId)); + }; + + updateEvents = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + const { startMoment } = this.state; + + store.scheduleStore.updateItem(scheduleId); // to refresh current oncall users + store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users + + return Promise.all([ + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'), + ]); + }; + + handleCreateRotation = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleCreateOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleUpdateRotation = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleDeleteRotation = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleDeleteOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleUpdateOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleTimezoneChange = (value: Timezone) => { + const { store } = this.props; + + const oldTimezone = store.currentTimezone; + + this.setState((oldState) => { + const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks'); + + return { ...oldState, startMoment: getStartOfWeek(value).add(wDiff, 'weeks') }; + }, this.updateEvents); + + store.currentTimezone = value; + }; + + handleShedulePeriodTypeChange = (value: string) => { + this.setState({ schedulePeriodType: value }); + }; + + handleRenderTypeChange = (value: string) => { + this.setState({ renderType: value }); + }; + + handleTodayClick = () => { + const { store } = this.props; + + this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }, this.updateEvents); + }; + + handleLeftClick = () => { + const { startMoment } = this.state; + + this.setState({ startMoment: startMoment.add(-7, 'day') }, this.updateEvents); + }; + + handleDelete = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + store.scheduleStore.delete(scheduleId).then(() => { + getLocationSrv().update({ query: { page: 'schedules-new' } }); + }); + }; + + handleRightClick = () => { + const { startMoment } = this.state; + + this.setState({ startMoment: startMoment.add(7, 'day') }, this.updateEvents); + }; +} + +export default withMobXProviderContext(SchedulePage); diff --git a/grafana-plugin/src/pages/schedules/Schedules.module.css b/grafana-plugin/src/pages/schedules/Schedules.module.css index cc0cc165..102be56c 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules/Schedules.module.css @@ -53,12 +53,12 @@ text-align: center; font-size: 14px; font-weight: 500; + flex-shrink: 0; } .gap-between-shifts { width: 520px; - height: 32px; - padding: 4px 4px 24px 4px; + padding: 5px 5px 5px 24px; background-color: rgba(209, 14, 92, 0.15); border: 1px solid rgba(209, 14, 92, 0.15); border-radius: 50px; @@ -66,7 +66,3 @@ font-weight: 400; align-items: baseline; } - -.gap-between-shifts-icon { - margin-left: 24px; -} diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 97cb3112..8c5ee23e 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -5,11 +5,10 @@ import { getLocationSrv } from '@grafana/runtime'; import { Button, ConfirmModal, - Modal, - DatePickerWithInput, HorizontalGroup, Icon, LoadingPlaceholder, + Modal, PENDING_COLOR, Tooltip, VerticalGroup, @@ -17,7 +16,7 @@ import { import cn from 'classnames/bind'; import { omit } from 'lodash-es'; import { observer } from 'mobx-react'; -import moment, { Moment } from 'moment-timezone'; +import moment from 'moment-timezone'; import instructionsImage from 'assets/img/events_instructions.png'; import Avatar from 'components/Avatar/Avatar'; @@ -36,7 +35,7 @@ import { import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { Schedule, ScheduleEvent } from 'models/schedule/schedule.types'; +import { Schedule, ScheduleEvent, ScheduleType } from 'models/schedule/schedule.types'; import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; @@ -231,41 +230,43 @@ class SchedulesPage extends React.Component )} -
    - {scheduleIdToEdit && ( - { - this.setState({ scheduleIdToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); - }} - /> - )} - {scheduleIdToDelete && ( - { - this.setState({ scheduleIdToDelete: undefined }); - }} - /> - )} - {scheduleIdToExport && ( - this.setState({ scheduleIdToExport: undefined })} - > - - - )} - + rowKey="id" + columns={columns} + data={schedules} + expandable={{ + expandedRowRender: this.renderEvents, + expandRowByClick: true, + onExpand: this.onRowExpand, + expandedRowKeys: expandedSchedulesKeys, + onExpandedRowsChange: this.handleExpandedRowsChange, + }} + /> + ) : ( + + You haven’t added a schedule yet. + + + + + } + /> + )} +
    + {scheduleIdToEdit && ( + { + this.setState({ scheduleIdToEdit: undefined }); + getLocationSrv().update({ partial: true, query: { id: undefined } }); + }} + /> )} ); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css new file mode 100644 index 00000000..4857e7cc --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -0,0 +1,22 @@ +.root { + margin-top: 24px; +} + +.quality__type_success { + color: var(--warning-text-color); +} + +.schedule { + position: relative; + margin: 20px 0; +} + +.loader { + padding-left: 20px; +} + +/* +.root .expanded-row { + background: var(--secondary-background); +} +*/ diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx new file mode 100644 index 00000000..aa979db6 --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -0,0 +1,382 @@ +import React from 'react'; + +import { getLocationSrv } from '@grafana/runtime'; +import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { debounce } from 'lodash-es'; +import { observer } from 'mobx-react'; + +import Avatar from 'components/Avatar/Avatar'; +import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; +import PluginLink from 'components/PluginLink/PluginLink'; +import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; +import SchedulesFilters from 'components/SchedulesFilters_NEW/SchedulesFilters'; +import { SchedulesFiltersType } from 'components/SchedulesFilters_NEW/SchedulesFilters.types'; +import Table from 'components/Table/Table'; +import Text from 'components/Text/Text'; +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; +import { getFromString } from 'models/schedule/schedule.helpers'; +import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; +import { AppFeature } from 'state/features'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './Schedules.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesPageProps extends WithStoreProps {} + +interface SchedulesPageState { + startMoment: dayjs.Dayjs; + filters: SchedulesFiltersType; + showNewScheduleSelector: boolean; + expandedRowKeys: Array; +} + +@observer +class SchedulesPage extends React.Component { + constructor(props: SchedulesPageProps) { + super(props); + + const { store } = this.props; + this.state = { + startMoment: getStartOfWeek(store.currentTimezone), + filters: { searchTerm: '', status: 'all', type: ScheduleType.API }, + showNewScheduleSelector: false, + expandedRowKeys: [], + }; + } + + async componentDidMount() { + const { store } = this.props; + + /* if (!store.hasFeature(AppFeature.WebSchedules)) { + getLocationSrv().update({ query: { page: 'schedules' } }); + } */ + + store.userStore.updateItems(); + store.scheduleStore.updateItems(); + } + + render() { + const { store } = this.props; + const { filters, showNewScheduleSelector, expandedRowKeys } = this.state; + + const { scheduleStore } = store; + + const schedules = scheduleStore.getSearchResult(/*filters.searchTerm*/); + const columns = [ + { + width: '10%', + title: 'Status', + key: 'name', + render: this.renderStatus, + }, + { + width: '40%', + title: 'Name', + key: 'name', + render: this.renderName, + }, + { + width: '45%', + title: 'Oncall', + key: 'users', + render: this.renderOncallNow, + }, + /* { + width: '20%', + title: 'ChatOps', + key: 'chatops', + render: this.renderChatOps, + },*/ + /*{ + width: '10%', + title: 'Quality', + key: 'quality', + render: this.renderQuality, + },*/ + { + width: '5%', + key: 'buttons', + render: this.renderButtons, + }, + ]; + + const users = store.userStore.getSearchResult().results; + + const data = schedules + ? schedules + .filter((schedule) => schedule.type === ScheduleType.API) + .filter( + (schedule) => + filters.status === 'all' || + (filters.status === 'used' && schedule.number_of_escalation_chains) || + (filters.status === 'unused' && !schedule.number_of_escalation_chains) + ) + .filter((schedule) => !filters.searchTerm || schedule.name.includes(filters.searchTerm)) + : undefined; + + return ( + <> +
    + + + + + {users && ( + + )} + + + +
    + {data ? Not found : Loading schedules...} + + } + /> + + + {showNewScheduleSelector && ( + { + this.setState({ showNewScheduleSelector: false }); + }} + /> + )} + + ); + } + + handleTimezoneChange = (value: Timezone) => { + const { store } = this.props; + + store.currentTimezone = value; + + this.setState({ startMoment: getStartOfWeek(value) }, this.updateEvents); + }; + + handleCreateScheduleClick = () => { + this.setState({ showNewScheduleSelector: true }); + }; + + handleCreateSchedule = (data: Schedule) => { + const { store } = this.props; + + if (data.type === ScheduleType.API) { + getLocationSrv().update({ query: { page: 'schedule', id: data.id } }); + } + }; + + handleExpandRow = (expanded: boolean, data: Schedule) => { + const { store } = this.props; + const { expandedRowKeys } = this.state; + const { startMoment } = this.state; + if (expanded && !expandedRowKeys.includes(data.id)) { + this.setState({ expandedRowKeys: [...this.state.expandedRowKeys, data.id] }, this.updateEvents); + } else if (!expanded && expandedRowKeys.includes(data.id)) { + const index = expandedRowKeys.indexOf(data.id); + const newExpandedRowKeys = [...expandedRowKeys]; + newExpandedRowKeys.splice(index, 1); + this.setState({ expandedRowKeys: newExpandedRowKeys }, this.updateEvents); + } + }; + + updateEvents = () => { + const { store } = this.props; + const { expandedRowKeys, startMoment } = this.state; + + expandedRowKeys.forEach((scheduleId) => { + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'); + }); + }; + + renderSchedule = (data: Schedule) => { + const { startMoment } = this.state; + const { store } = this.props; + + return ( +
    + +
    + +
    +
    + ); + }; + + getScheduleClickHandler = (scheduleId: Schedule['id']) => { + return () => { + getLocationSrv().update({ query: { page: 'schedule', id: scheduleId } }); + }; + }; + + renderStatus = (item: Schedule) => { + const { + store: { scheduleStore }, + } = this.props; + + const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id]; + + return ( + + + {relatedEscalationChains ? ( + relatedEscalationChains.length ? ( + relatedEscalationChains.map((escalationChain) => ( + + {escalationChain.name} + + )) + ) : ( + 'Not used yet' + ) + ) : ( + Loading related escalation chains.... + )} + + } + onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)} + /> + {/* */} + + ); + }; + + renderName = (item: Schedule) => { + return {item.name}; + }; + + renderOncallNow = (item: Schedule, index: number) => { + if (item.on_call_now?.length > 0) { + return ( + + {item.on_call_now.map((user, index) => { + return ( + +
    + + {user.username} +
    +
    + ); + })} +
    + ); + } + return null; + }; + + /* renderChatOps = (item: Schedule) => { + return item.chatOps; + }; */ + + /* renderQuality = (item: Schedule) => { + const type = item.quality > 70 ? 'primary' : 'warning'; + + return {item.quality || 70}%; + }; */ + + renderButtons = (item: Schedule) => { + return ( + + {/* + + */} + + + + + ); + }; + + getDeleteScheduleClickHandler = (id: Schedule['id']) => { + const { store } = this.props; + const { scheduleStore } = store; + + return () => { + scheduleStore.delete(id).then(this.update); + }; + }; + + handleSchedulesFiltersChange = (filters: SchedulesFiltersType) => { + this.setState({ filters }, this.debouncedUpdateSchedules); + }; + + applyFilters = () => { + const { filters } = this.state; + const { store } = this.props; + const { scheduleStore } = store; + + // scheduleStore.updateItems(filters.searchTerm); + }; + + debouncedUpdateSchedules = debounce(this.applyFilters, 1000); + + handlePageChange = (page: number) => {}; + + update = () => { + const { store } = this.props; + const { scheduleStore } = store; + + return scheduleStore.updateItems(); + }; + + getUpdateRelatedEscalationChainsHandler = (scheduleId: Schedule['id']) => { + const { store } = this.props; + const { scheduleStore } = store; + + return () => { + scheduleStore.updateRelatedEscalationChains(scheduleId).then(() => { + this.forceUpdate(); + }); + }; + }; +} + +export default withMobXProviderContext(SchedulesPage); diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 4f5132f1..b3a4705a 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -44,6 +44,13 @@ "addToNav": true, "defaultNav": true }, + { + "type": "page", + "name": "Users", + "path": "/a/grafana-oncall-app/?page=users", + "role": "Viewer", + "addToNav": true + }, { "type": "page", "name": "Integrations", @@ -57,13 +64,6 @@ "role": "Viewer", "addToNav": true }, - { - "type": "page", - "name": "Users", - "path": "/a/grafana-oncall-app/?page=users", - "role": "Viewer", - "addToNav": true - }, { "type": "page", "name": "Schedules", @@ -78,6 +78,13 @@ "role": "Viewer", "addToNav": true }, + { + "type": "page", + "name": "Outgoing Webhooks", + "path": "/a/grafana-oncall-app/?page=outgoing_webhooks", + "role": "Viewer", + "addToNav": true + }, { "type": "page", "name": "Maintenance", @@ -91,13 +98,6 @@ "path": "/a/grafana-oncall-app/?page=settings", "role": "Viewer", "addToNav": true - }, - { - "type": "page", - "name": "Outgoing Webhooks", - "path": "/a/grafana-oncall-app/?page=outgoing_webhooks", - "role": "Viewer", - "addToNav": true } ], "routes": [ diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index bf915f19..856d26d0 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -5,4 +5,5 @@ export enum AppFeature { MobileApp = 'mobile_app', CloudNotifications = 'grafana_cloud_notifications', CloudConnection = 'grafana_cloud_connection', + WebSchedules = 'web_schedules', } diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 3746d9eb..605ba9f8 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -1,6 +1,7 @@ import { AppPluginMeta } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { action, observable } from 'mobx'; +import moment from 'moment-timezone'; import qs from 'query-string'; import { OnCallAppSettings } from 'types'; @@ -24,6 +25,7 @@ import { SlackStore } from 'models/slack/slack'; import { SlackChannelStore } from 'models/slack_channel/slack_channel'; import { TeamStore } from 'models/team/team'; import { TelegramChannelStore } from 'models/telegram_channel/telegram_channel'; +import { Timezone } from 'models/timezone/timezone.types'; import { UserStore } from 'models/user/user'; import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; @@ -38,6 +40,9 @@ export class RootBaseStore { @observable appLoading = true; + @observable + currentTimezone: Timezone = moment.tz.guess() as Timezone; + @observable backendVersion = ''; @@ -85,6 +90,9 @@ export class RootBaseStore { @observable incidentsPage: any = this.initialQuery.p ? Number(this.initialQuery.p) : 1; + @observable + onCallApiUrl: string; + // -------------------------- userStore: UserStore = new UserStore(this); @@ -182,6 +190,8 @@ export class RootBaseStore { return; } + this.onCallApiUrl = meta.jsonData.onCallApiUrl; + let syncStartStatus = await this.startSync(); if (syncStartStatus.is_user_anonymous) { this.isUserAnonymous = true; @@ -215,7 +225,7 @@ export class RootBaseStore { this.handleSyncException(e); }); - if (counter >= 5) { + if (counter >= 10) { clearInterval(interval); this.retrySync = true; } diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index eb261c2e..b640158c 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -28,6 +28,7 @@ --primary-text-link: #1f62e0; --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); + --oncall-icon-stroke-color: #fff; } .theme-dark { @@ -46,4 +47,10 @@ --primary-text-link: #6e9fff; --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1); + --focused-box-shadow: rgb(17 18 23) 0 0 0 2px, rgb(61 113 217) 0 0 0 4px; + --border-medium: 1px solid rgba(204, 204, 220, 0.15); + --hover-selected: rgba(204, 204, 220, 0.12); + --hover-selected-hardcoded: #34363d; + --secondary-background-shade: rgba(204, 204, 220, 0.2); + --oncall-icon-stroke-color: #181b1f; } diff --git a/grafana-plugin/src/utils/DOM.ts b/grafana-plugin/src/utils/DOM.ts new file mode 100644 index 00000000..b1ebae8c --- /dev/null +++ b/grafana-plugin/src/utils/DOM.ts @@ -0,0 +1,38 @@ +export const waitForElement = (selector: string) => { + return new Promise((resolve) => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver((mutations) => { + if (document.querySelector(selector)) { + resolve(document.querySelector(selector)); + observer.disconnect(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +}; + +export const getCoords = (elem) => { + // crossbrowser version + var box = elem.getBoundingClientRect(); + + var body = document.body; + var docEl = document.documentElement; + + var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; + var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + + var clientTop = docEl.clientTop || body.clientTop || 0; + var clientLeft = docEl.clientLeft || body.clientLeft || 0; + + var top = box.top + scrollTop - clientTop; + var left = box.left + scrollLeft - clientLeft; + + return { top: Math.round(top), left: Math.round(left) }; +}; diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index 93052831..7c4adc74 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { AppRootProps, NavModelItem } from '@grafana/data'; @@ -18,11 +17,12 @@ type Args = { }; enableLiveSettings: boolean; enableCloudPage: boolean; + enableNewSchedulesPage: boolean; backendLicense: string; }; export function useForceUpdate() { - const [value, setValue] = useState(0); + const [, setValue] = useState(0); return () => setValue((value) => value + 1); } @@ -34,6 +34,7 @@ export function useNavModel({ grafanaUser, enableLiveSettings, enableCloudPage, + enableNewSchedulesPage, backendLicense, }: Args) { return useMemo(() => { @@ -49,7 +50,8 @@ export function useNavModel({ hideFromTabs || (role === 'Admin' && grafanaUser.orgRole !== role) || (id === 'live-settings' && !enableLiveSettings) || - (id === 'cloud' && !enableCloudPage), + (id === 'cloud' && !enableCloudPage) || + (id === 'schedules-new' && !enableNewSchedulesPage), }); if (page === id) { @@ -74,7 +76,17 @@ export function useNavModel({ node, main: node, }; - }, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]); + }, [ + meta.info.logos.large, + pages, + path, + page, + enableLiveSettings, + enableCloudPage, + backendLicense, + enableNewSchedulesPage, + grafanaUser.orgRole, + ]); } export function usePrevious(value: any) { diff --git a/grafana-plugin/tsconfig.json b/grafana-plugin/tsconfig.json index eb27527f..add73ef9 100644 --- a/grafana-plugin/tsconfig.json +++ b/grafana-plugin/tsconfig.json @@ -9,6 +9,6 @@ "noUnusedLocals": false, "strict": false, "resolveJsonModule": true, - "noImplicitAny": false + "noImplicitAny": false, } } diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 6dda27d4..a4440abf 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1381,6 +1381,15 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~2.2.5" +"@es-joy/jsdoccomment@~0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz#dbc342cc38eb6878c12727985e693eaef34302bc" + integrity sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ== + dependencies: + comment-parser "1.3.1" + esquery "^1.4.0" + jsdoc-type-pratt-parser "~3.1.0" + "@eslint/eslintrc@^1.2.1", "@eslint/eslintrc@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" @@ -1396,6 +1405,21 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + "@formatjs/ecma402-abstract@1.11.10": version "1.11.10" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.10.tgz#1b61909ce069d1fa62bafb163aaff59d524c094d" @@ -1714,6 +1738,15 @@ uplot "1.6.22" uuid "8.3.2" +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -1723,6 +1756,16 @@ debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3012,7 +3055,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react-transition-group@^4.4.0": +"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== @@ -3167,6 +3210,21 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/eslint-plugin@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/parser@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.16.0.tgz#e4de1bde4b4dad5b6124d3da227347616ed55508" @@ -3185,6 +3243,14 @@ "@typescript-eslint/types" "5.16.0" "@typescript-eslint/visitor-keys" "5.16.0" +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + "@typescript-eslint/type-utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz#b482bdde1d7d7c0c7080f7f2f67ea9580b9e0692" @@ -3194,11 +3260,26 @@ debug "^4.3.2" tsutils "^3.21.0" +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + tsutils "^3.21.0" + "@typescript-eslint/types@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee" integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g== +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + "@typescript-eslint/typescript-estree@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz#32259459ec62f5feddca66adc695342f30101f61" @@ -3212,6 +3293,19 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.16.0.tgz#42218b459d6d66418a4eb199a382bdc261650679" @@ -3224,6 +3318,18 @@ eslint-scope "^5.1.1" eslint-utils "^3.0.0" +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + "@typescript-eslint/visitor-keys@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz#f27dc3b943e6317264c7492e390c6844cd4efbbb" @@ -3232,6 +3338,14 @@ "@typescript-eslint/types" "5.16.0" eslint-visitor-keys "^3.0.0" +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3620,6 +3734,11 @@ array-includes@^3.1.5: get-intrinsic "^1.1.1" is-string "^1.0.7" +array-move@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/array-move/-/array-move-4.0.0.tgz#2c3730f056cc926f62a59769a5a8cda2fb6d8c55" + integrity sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ== + array-slice@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz" @@ -3649,7 +3768,7 @@ array.prototype.flat@^1.2.5: define-properties "^1.1.3" es-abstract "^1.19.0" -array.prototype.flatmap@^1.2.5: +array.prototype.flatmap@^1.2.5, array.prototype.flatmap@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz#a7e8ed4225f4788a70cd910abcf0791e76a5534f" integrity sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg== @@ -5059,6 +5178,11 @@ date-fns@2.29.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.1.tgz#9667c2615525e552b5135a3116b95b1961456e60" integrity sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw== +dayjs@^1.11.5: + version "1.11.5" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93" + integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA== + debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5609,12 +5733,25 @@ eslint-plugin-jsdoc@38.0.6: semver "^7.3.5" spdx-expression-parse "^3.0.1" +eslint-plugin-jsdoc@^39.3.6: + version "39.3.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz#6ba29f32368d72a51335a3dc9ccd22ad0437665d" + integrity sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g== + dependencies: + "@es-joy/jsdoccomment" "~0.31.0" + comment-parser "1.3.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.4.0" + semver "^7.3.7" + spdx-expression-parse "^3.0.1" + eslint-plugin-react-hooks@4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz#318dbf312e06fab1c835a4abef00121751ac1172" integrity sha512-XslZy0LnMn+84NEG9jSGR6eGqaZB3133L8xewQo3fQagbQuGt7a63gf+P1NGKZavEYEC3UXaWEAA/AqDkuN6xA== -eslint-plugin-react-hooks@4.6.0: +eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== @@ -5639,6 +5776,26 @@ eslint-plugin-react@7.29.4: semver "^6.3.0" string.prototype.matchall "^4.0.6" +eslint-plugin-react@^7.31.7: + version "7.31.7" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.31.7.tgz#36fb1c611a7db5f757fce09cbbcc01682f8b0fbb" + integrity sha512-8NldBTeYp/kQoTV1uT0XF6HcmDqbgZ0lNPkN0wlRw8DJKXEnaWu+oh/6gt3xIhzvQ35wB2Y545fJhIbJSZ2NNw== + dependencies: + array-includes "^3.1.5" + array.prototype.flatmap "^1.3.0" + doctrine "^2.1.0" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.5" + object.fromentries "^2.0.5" + object.hasown "^1.1.1" + object.values "^1.1.5" + prop-types "^15.8.1" + resolve "^2.0.0-next.3" + semver "^6.3.0" + string.prototype.matchall "^4.0.7" + eslint-plugin-rulesdir@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.1.tgz" @@ -5759,6 +5916,51 @@ eslint@8.20.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== + dependencies: + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + functional-red-black-tree "^1.0.1" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + espree@^9.3.1, espree@^9.3.2: version "9.3.3" resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" @@ -5768,6 +5970,15 @@ espree@^9.3.1, espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -5854,7 +6065,7 @@ execall@^2.0.0: dependencies: clone-regexp "^2.1.0" -exenv@^1.2.2: +exenv@^1.2.0, exenv@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== @@ -6449,7 +6660,7 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.3, globby@^11.0.4: +globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -6489,6 +6700,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -7868,6 +8084,11 @@ jsdoc-type-pratt-parser@~2.2.5: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-2.2.5.tgz#c9f93afac7ee4b5ed4432fe3f09f7d36b05ed0ff" integrity sha512-2a6eRxSxp1BW040hFvaJxhsCMI9lT8QB8t14t+NY5tC5rckIR0U9cr2tjOeaFirmEOy6MHvmJnY7zTBHq431Lw== +jsdoc-type-pratt-parser@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e" + integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw== + jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -8898,7 +9119,7 @@ object.fromentries@^2.0.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.hasown@^1.1.0: +object.hasown@^1.1.0, object.hasown@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.1.tgz#ad1eecc60d03f49460600430d97f23882cf592a3" integrity sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A== @@ -9946,7 +10167,7 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@2.7.1: +prettier@2.7.1, prettier@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== @@ -10381,6 +10602,14 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-draggable@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.5.tgz#9e37fe7ce1a4cf843030f521a0a4cc41886d7e7c" + integrity sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g== + dependencies: + clsx "^1.1.1" + prop-types "^15.8.1" + react-dropzone@14.2.2: version "14.2.2" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.2.tgz#a75a0676055fe9e2cb78578df4dedb4c42b54f98" @@ -10455,11 +10684,21 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-modal@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.15.1.tgz#950ce67bfef80971182dd0ed38f2d9b1a681288b" + integrity sha512-duB9bxOaYg7Zt6TMFldIFxQRtSP+Dg3F1ZX3FXxSUn+3tZZ/9JCgeAQKDg7rhZSAqopq8TFRw3yIbnx77gyFTw== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-popper-tooltip@^4.3.1: version "4.4.2" resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163" @@ -10579,7 +10818,7 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" -react-transition-group@^4.3.0, react-transition-group@^4.4.2: +react-transition-group@^4.3.0, react-transition-group@^4.4.2, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -11580,7 +11819,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2 is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string.prototype.matchall@^4.0.6: +string.prototype.matchall@^4.0.6, string.prototype.matchall@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d" integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg== @@ -12486,7 +12725,7 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" -warning@^4.0.2: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==