Merge branch 'dev' into web-schedule-fixes
# Conflicts: # engine/apps/schedules/ical_utils.py # engine/apps/schedules/models/custom_on_call_shift.py
This commit is contained in:
commit
461f284bb5
106 changed files with 2532 additions and 1615 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,8 +2,10 @@
|
|||
*/db.sqlite3
|
||||
*.pyc
|
||||
venv
|
||||
.python-version
|
||||
.env
|
||||
.env_hobby
|
||||
.env.dev
|
||||
.vscode
|
||||
dump.rdb
|
||||
.idea
|
||||
|
|
|
|||
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -1,71 +1,110 @@
|
|||
# Change Log
|
||||
|
||||
## v1.0.39 (2022-10-03)
|
||||
|
||||
- Fix issue in v1.0.38 blocking the creation of schedules and webhooks in the UI
|
||||
|
||||
## v1.0.38 (2022-09-30)
|
||||
|
||||
- Fix exception handling for adding resolution notes when slack and oncall users are out of sync.
|
||||
- Fix all day events showing as having gaps in slack notifications
|
||||
- Improve plugin configuration error message readability
|
||||
- Add `telegram` key to `permalinks` property in `AlertGroup` public API response schema
|
||||
|
||||
## v1.0.37 (2022-09-21)
|
||||
|
||||
- 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
|
||||
|
|
@ -73,15 +112,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
|
||||
|
|
@ -89,18 +131,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.
|
||||
|
|
|
|||
112
DEVELOPER.md
112
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
|
||||
|
|
@ -46,15 +56,15 @@ python --version
|
|||
# Make sure you have latest pip and wheel support
|
||||
pip install -U pip wheel
|
||||
|
||||
# Copy and check .env file.
|
||||
cp .env.example .env
|
||||
# Copy and check .env.dev file.
|
||||
cp .env.dev.example .env.dev
|
||||
|
||||
# NOTE: if you want to use the PostgreSQL db backend add DB_BACKEND=postgresql to your .env file;
|
||||
# NOTE: if you want to use the PostgreSQL db backend add DB_BACKEND=postgresql to your .env.dev file;
|
||||
# currently allowed backend values are `mysql` (default) and `postgresql`
|
||||
|
||||
# Apply .env to current terminal.
|
||||
# Apply .env.dev to current terminal.
|
||||
# For PyCharm it's better to use https://plugins.jetbrains.com/plugin/7861-envfile/
|
||||
export $(grep -v '^#' .env | xargs -0)
|
||||
export $(grep -v '^#' .env.dev | xargs -0)
|
||||
|
||||
# Install dependencies.
|
||||
# Hint: there is a known issue with uwsgi. It's not used in the local dev environment. Feel free to comment it in `engine/requirements.txt`.
|
||||
|
|
@ -67,27 +77,27 @@ 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
|
||||
|
||||
# Worker for background tasks (run it in the parallel terminal, don't forget to export .env there)
|
||||
# Worker for background tasks (run it in the parallel terminal, don't forget to export .env.dev there)
|
||||
python manage.py start_celery
|
||||
|
||||
# Additionally you could launch the worker with periodic tasks launcher (99% you don't need this)
|
||||
celery -A engine beat -l info
|
||||
```
|
||||
|
||||
4. All set! Check out internal API endpoints at http://localhost:8080/.
|
||||
|
||||
5. All set! Check out internal API endpoints at http://localhost:8000/.
|
||||
|
||||
### Frontend setup
|
||||
|
||||
1. Make sure you have [NodeJS v.14+ < 17](https://nodejs.org/) and [yarn](https://yarnpkg.com/) installed.
|
||||
1. Make sure you have [NodeJS v.14+ < 17](https://nodejs.org/) and [yarn](https://yarnpkg.com/) installed. **Note**: If you are using [`nvm`](https://github.com/nvm-sh/nvm) feel free to simply run `cd grafana-plugin && nvm install` to install the proper Node version.
|
||||
|
||||
2. Install the dependencies with `yarn` and launch the frontend server (on port `3000` by default)
|
||||
|
||||
```bash
|
||||
cd grafana-plugin
|
||||
yarn install
|
||||
|
|
@ -96,19 +106,21 @@ yarn watch
|
|||
```
|
||||
|
||||
3. Ensure /grafana-plugin/provisioning has no grafana-plugin.yml
|
||||
|
||||
4. Generate an invitation token:
|
||||
|
||||
```bash
|
||||
cd engine;
|
||||
python manage.py issue_invite_for_the_frontend --override
|
||||
```
|
||||
|
||||
... or use output of all-in-one docker container described in the README.md.
|
||||
|
||||
5. Open Grafana in the browser http://localhost:3000 (login: oncall, password: oncall) notice OnCall Plugin is not enabled, navigate to Configuration->Plugins and click Grafana OnCall
|
||||
|
||||
6. Some configuration fields will appear be available. Fill them out and click Initialize OnCall
|
||||
|
||||
```
|
||||
OnCall API URL:
|
||||
OnCall API URL:
|
||||
http://host.docker.internal:8080
|
||||
|
||||
Invitation Token (Single use token to connect Grafana instance):
|
||||
|
|
@ -120,6 +132,7 @@ http://localhost:3000
|
|||
|
||||
NOTE: you may not have `host.docker.internal` available, in that case you can get the
|
||||
host IP from inside the container by running:
|
||||
|
||||
```bash
|
||||
/sbin/ip route|awk '/default/ { print $3 }'
|
||||
|
||||
|
|
@ -133,13 +146,14 @@ extra_hosts:
|
|||
|
||||
For Slack app configuration check our docs: https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup
|
||||
|
||||
|
||||
### Update drone build
|
||||
The .drone.yml build file must be signed when changes are made to it. Follow these steps:
|
||||
|
||||
The .drone.yml build file must be signed when changes are made to it. Follow these steps:
|
||||
|
||||
If you have not installed drone CLI follow [these instructions](https://docs.drone.io/cli/install/)
|
||||
|
||||
To sign the .drone.yml file:
|
||||
|
||||
```bash
|
||||
export DRONE_SERVER=https://drone.grafana.net
|
||||
|
||||
|
|
@ -154,6 +168,7 @@ drone sign --save grafana/oncall .drone.yml
|
|||
### ld: library not found for -lssl
|
||||
|
||||
**Problem:**
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
...
|
||||
|
|
@ -162,6 +177,7 @@ pip install -r requirements.txt
|
|||
error: command 'gcc' failed with exit status 1
|
||||
...
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```
|
||||
|
|
@ -174,6 +190,7 @@ pip install -r requirements.txt
|
|||
Happens on Apple Silicon
|
||||
|
||||
**Problem:**
|
||||
|
||||
```
|
||||
build/temp.macosx-12-arm64-3.9/_openssl.c:575:10: fatal error: 'openssl/opensslv.h' file not found
|
||||
#include <openssl/opensslv.h>
|
||||
|
|
@ -183,7 +200,9 @@ Happens on Apple Silicon
|
|||
----------------------------------------
|
||||
ERROR: Failed building wheel for cryptography
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```
|
||||
LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1.1)/include" pip install `cat requirements.txt | grep cryptography`
|
||||
```
|
||||
|
|
@ -191,6 +210,7 @@ LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" CFLAGS="-I$(brew --prefix openssl@1
|
|||
### django.db.utils.OperationalError: (1366, "Incorrect string value ...")
|
||||
|
||||
**Problem:**
|
||||
|
||||
```
|
||||
django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x98\\x8A\\xF0\\x9F...' for column 'cached_name' at row 1")
|
||||
```
|
||||
|
|
@ -198,15 +218,15 @@ django.db.utils.OperationalError: (1366, "Incorrect string value: '\\xF0\\x9F\\x
|
|||
**Solution:**
|
||||
|
||||
Recreate the database with the correct encoding.
|
||||
|
||||
### Grafana OnCall plugin does not show up in plugin list
|
||||
|
||||
|
||||
### Grafana OnCall plugin does not show up in plugin list
|
||||
|
||||
**Problem:**
|
||||
I've run `yarn watch` in `grafana_plugin` but I do not see Grafana OnCall in the list of plugins
|
||||
|
||||
|
||||
**Solution:**
|
||||
If it is the first time you have run `yarn watch` and it was run after starting Grafana in docker-compose; Grafana will not have detected a plugin to fix: `docker-compose -f developer-docker-compose.yml restart grafana`
|
||||
|
||||
|
||||
## Hints:
|
||||
|
||||
### Building the all-in-one docker container
|
||||
|
|
@ -219,14 +239,16 @@ 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
|
||||
|
||||
In the `engine` directory, with the `.env` vars exported and virtualenv activated
|
||||
In the `engine` directory, with the `.env.dev` vars exported and virtualenv activated
|
||||
|
||||
```bash
|
||||
pytest
|
||||
|
|
@ -239,23 +261,23 @@ pip install pytest.xdist
|
|||
pytest -n4
|
||||
```
|
||||
|
||||
|
||||
## IDE Specific Instructions
|
||||
|
||||
### PyCharm
|
||||
1. Create venv and copy .env file
|
||||
|
||||
1. Create venv and copy .env.dev file
|
||||
```bash
|
||||
python3.9 -m venv venv
|
||||
cp .env.example .env
|
||||
cp .env.dev.example .env.dev
|
||||
```
|
||||
2. Open the project in PyCharm
|
||||
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
|
||||
5. Create a new Django Server run configuration to Run/Debug the engine
|
||||
- Use a plugin such as EnvFile to load the .env file
|
||||
- Use a plugin such as EnvFile to load the .env.dev file
|
||||
- Change port from 8000 to 8080
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -21,50 +21,55 @@ Developer-friendly incident response with brilliant Slack integration.
|
|||
We prepared multiple environments: [production](https://grafana.com/docs/grafana-cloud/oncall/open-source/#production-environment), [developer](DEVELOPER.md) and hobby:
|
||||
|
||||
1. Download docker-compose.yaml:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.yml -o docker-compose.yml
|
||||
```
|
||||
|
||||
2. Set variables:
|
||||
|
||||
```bash
|
||||
echo "DOMAIN=http://localhost:8080
|
||||
COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana
|
||||
SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long
|
||||
RABBITMQ_PASSWORD=rabbitmq_secret_pw
|
||||
MYSQL_PASSWORD=mysql_secret_pw
|
||||
COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana
|
||||
GRAFANA_USER=admin
|
||||
GRAFANA_PASSWORD=admin" > .env_hobby
|
||||
MYSQL_PASSWORD=mysql_secret_pw" > .env
|
||||
```
|
||||
|
||||
3. Launch services:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file .env_hobby -f docker-compose.yml up -d
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. Issue one-time invite token:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file .env_hobby -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override
|
||||
docker-compose run engine python manage.py issue_invite_for_the_frontend --override
|
||||
```
|
||||
|
||||
**Note**: if you remove the plugin configuration and reconfigure it, you will need to generate a new one-time invite token for your new configuration.
|
||||
|
||||
5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_:
|
||||
|
||||
```
|
||||
Invite token: ^^^ from the previous step.
|
||||
OnCall backend URL: http://engine:8080
|
||||
Grafana Url: http://grafana:3000
|
||||
```
|
||||
|
||||
6. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud.
|
||||
|
||||
6. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud.
|
||||
|
||||
## Update version
|
||||
|
||||
To update your Grafana OnCall hobby environment:
|
||||
|
||||
```shell
|
||||
# Update Docker images
|
||||
docker-compose --env-file .env_hobby -f docker-compose.yml pull engine celery oncall_db_migration
|
||||
# Update Docker image
|
||||
docker-compose pull engine
|
||||
|
||||
# Re-deploy
|
||||
docker-compose --env-file .env_hobby -f docker-compose.yml up -d --remove-orphans
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
After updating the engine, you'll also need to click the "Update" button on the [plugin version page](http://localhost:3000/plugins/grafana-oncall-app?page=version-history).
|
||||
|
|
@ -76,14 +81,13 @@ See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin
|
|||
<a href="https://github.com/grafana/oncall/discussions"><img width="200px" src="docs/img/GH_discussions.png"></a>
|
||||
<a href="https://slack.grafana.com/"><img width="200px" src="docs/img/slack.png"></a>
|
||||
|
||||
|
||||
## Stargazers over time
|
||||
|
||||
[](https://starchart.cc/grafana/oncall)
|
||||
|
||||
|
||||
## Further Reading
|
||||
- *Migration from the PagerDuty* - [Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator)
|
||||
- *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/)
|
||||
- *Blog Post* - [Announcing Grafana OnCall, the easiest way to do on-call management](https://grafana.com/blog/2021/11/09/announcing-grafana-oncall/)
|
||||
- *Presentation* - [Deep dive into the Grafana, Prometheus, and Alertmanager stack for alerting and on-call management](https://grafana.com/go/observabilitycon/2021/alerting/?pg=blog)
|
||||
|
||||
- _Migration from the PagerDuty_ - [Migrator](https://github.com/grafana/oncall/tree/dev/tools/pagerduty-migrator)
|
||||
- _Documentation_ - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/)
|
||||
- _Blog Post_ - [Announcing Grafana OnCall, the easiest way to do on-call management](https://grafana.com/blog/2021/11/09/announcing-grafana-oncall/)
|
||||
- _Presentation_ - [Deep dive into the Grafana, Prometheus, and Alertmanager stack for alerting and on-call management](https://grafana.com/go/observabilitycon/2021/alerting/?pg=blog)
|
||||
|
|
|
|||
|
|
@ -1,52 +1,62 @@
|
|||
version: '3.2'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: postgres:14.4
|
||||
platform: linux/x86_64
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
restart: always
|
||||
ports:
|
||||
- 5432:5432
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: oncall_local_dev
|
||||
POSTGRES_PASSWORD: empty
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
|
||||
POSTGRES_INITDB_ARGS: --encoding=UTF-8
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
mem_limit: 100m
|
||||
cpus: 0.1
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
- "6379:6379"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 100m
|
||||
cpus: '0.1'
|
||||
|
||||
rabbit:
|
||||
image: "rabbitmq:3.7.15-management"
|
||||
mem_limit: 1000m
|
||||
cpus: 0.5
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "rabbitmq"
|
||||
RABBITMQ_DEFAULT_PASS: "rabbitmq"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1000m
|
||||
cpus: '0.5'
|
||||
ports:
|
||||
- 15672:15672
|
||||
- 5672:5672
|
||||
- "15672:15672"
|
||||
- "5672:5672"
|
||||
|
||||
mysql-to-create-grafana-db:
|
||||
image: mysql:5.7
|
||||
platform: linux/x86_64
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: always
|
||||
ports:
|
||||
- 3306:3306
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: empty
|
||||
MYSQL_DATABASE: grafana
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
|
||||
timeout: 20s
|
||||
|
|
@ -55,8 +65,6 @@ services:
|
|||
grafana:
|
||||
image: "grafana/grafana:main"
|
||||
restart: always
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
environment:
|
||||
GF_DATABASE_TYPE: mysql
|
||||
GF_DATABASE_HOST: mysql
|
||||
|
|
@ -65,10 +73,15 @@ services:
|
|||
GF_SECURITY_ADMIN_USER: oncall
|
||||
GF_SECURITY_ADMIN_PASSWORD: oncall
|
||||
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
volumes:
|
||||
- ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
mysql-to-create-grafana-db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
version: '3.2'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
platform: linux/x86_64
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: always
|
||||
ports:
|
||||
- 3306:3306
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: empty
|
||||
MYSQL_DATABASE: oncall_local_dev
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
|
||||
timeout: 20s
|
||||
|
|
@ -21,23 +23,29 @@ services:
|
|||
|
||||
redis:
|
||||
image: redis
|
||||
mem_limit: 100m
|
||||
cpus: 0.1
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
- "6379:6379"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 100m
|
||||
cpus: '0.1'
|
||||
|
||||
rabbit:
|
||||
image: "rabbitmq:3.7.15-management"
|
||||
mem_limit: 1000m
|
||||
cpus: 0.5
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "rabbitmq"
|
||||
RABBITMQ_DEFAULT_PASS: "rabbitmq"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1000m
|
||||
cpus: '0.5'
|
||||
ports:
|
||||
- 15672:15672
|
||||
- 5672:5672
|
||||
- "15672:15672"
|
||||
- "5672:5672"
|
||||
|
||||
mysql-to-create-grafana-db:
|
||||
image: mysql:5.7
|
||||
|
|
@ -50,8 +58,6 @@ services:
|
|||
grafana:
|
||||
image: "grafana/grafana:main"
|
||||
restart: always
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
environment:
|
||||
GF_DATABASE_TYPE: mysql
|
||||
GF_DATABASE_HOST: mysql
|
||||
|
|
@ -60,10 +66,15 @@ services:
|
|||
GF_SECURITY_ADMIN_USER: oncall
|
||||
GF_SECURITY_ADMIN_PASSWORD: oncall
|
||||
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
volumes:
|
||||
- ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
version: "3.8"
|
||||
|
||||
x-environment:
|
||||
&oncall-environment
|
||||
BASE_URL: $DOMAIN
|
||||
SECRET_KEY: $SECRET_KEY
|
||||
RABBITMQ_USERNAME: "rabbitmq"
|
||||
RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD
|
||||
RABBITMQ_HOST: "rabbitmq"
|
||||
RABBITMQ_PORT: "5672"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
MYSQL_PASSWORD: $MYSQL_PASSWORD
|
||||
MYSQL_DB_NAME: oncall_hobby
|
||||
MYSQL_USER: ${MYSQL_USER:-root}
|
||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||
MYSQL_PORT: 3306
|
||||
REDIS_URI: redis://redis:6379/0
|
||||
DJANGO_SETTINGS_MODULE: settings.hobby
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
|
||||
CELERY_WORKER_CONCURRENCY: "1"
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD: "100"
|
||||
CELERY_WORKER_SHUTDOWN_INTERVAL: "65m"
|
||||
CELERY_WORKER_BEAT_ENABLED: "True"
|
||||
|
||||
services:
|
||||
engine:
|
||||
image: grafana/oncall
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
- "8080:8080"
|
||||
command: >
|
||||
sh -c "uwsgi --ini uwsgi.ini"
|
||||
environment:
|
||||
BASE_URL: $DOMAIN
|
||||
SECRET_KEY: $SECRET_KEY
|
||||
RABBITMQ_USERNAME: "rabbitmq"
|
||||
RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD
|
||||
RABBITMQ_HOST: "rabbitmq"
|
||||
RABBITMQ_PORT: "5672"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
MYSQL_PASSWORD: $MYSQL_PASSWORD
|
||||
MYSQL_DB_NAME: oncall_hobby
|
||||
MYSQL_USER: ${MYSQL_USER:-root}
|
||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||
MYSQL_PORT: 3306
|
||||
REDIS_URI: redis://redis:6379/0
|
||||
DJANGO_SETTINGS_MODULE: settings.hobby
|
||||
OSS: "True"
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
|
||||
environment: *oncall-environment
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
@ -37,27 +45,7 @@ services:
|
|||
image: grafana/oncall
|
||||
restart: always
|
||||
command: sh -c "./celery_with_exporter.sh"
|
||||
environment:
|
||||
BASE_URL: $DOMAIN
|
||||
SECRET_KEY: $SECRET_KEY
|
||||
RABBITMQ_USERNAME: "rabbitmq"
|
||||
RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD
|
||||
RABBITMQ_HOST: "rabbitmq"
|
||||
RABBITMQ_PORT: "5672"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
MYSQL_PASSWORD: $MYSQL_PASSWORD
|
||||
MYSQL_DB_NAME: oncall_hobby
|
||||
MYSQL_USER: ${MYSQL_USER:-root}
|
||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||
MYSQL_PORT: 3306
|
||||
REDIS_URI: redis://redis:6379/0
|
||||
DJANGO_SETTINGS_MODULE: settings.hobby
|
||||
OSS: "True"
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
|
||||
CELERY_WORKER_CONCURRENCY: "1"
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD: "100"
|
||||
CELERY_WORKER_SHUTDOWN_INTERVAL: "65m"
|
||||
CELERY_WORKER_BEAT_ENABLED: "True"
|
||||
environment: *oncall-environment
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
@ -71,23 +59,7 @@ services:
|
|||
oncall_db_migration:
|
||||
image: grafana/oncall
|
||||
command: python manage.py migrate --noinput
|
||||
environment:
|
||||
BASE_URL: $DOMAIN
|
||||
SECRET_KEY: $SECRET_KEY
|
||||
RABBITMQ_USERNAME: "rabbitmq"
|
||||
RABBITMQ_PASSWORD: $RABBITMQ_PASSWORD
|
||||
RABBITMQ_HOST: "rabbitmq"
|
||||
RABBITMQ_PORT: "5672"
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
MYSQL_PASSWORD: $MYSQL_PASSWORD
|
||||
MYSQL_DB_NAME: oncall_hobby
|
||||
MYSQL_USER: ${MYSQL_USER:-root}
|
||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||
MYSQL_PORT: 3306
|
||||
REDIS_URI: redis://redis:6379/0
|
||||
DJANGO_SETTINGS_MODULE: settings.hobby
|
||||
OSS: "True"
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
|
||||
environment: *oncall-environment
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
|
@ -97,8 +69,6 @@ services:
|
|||
mysql:
|
||||
image: mysql:5.7
|
||||
platform: linux/x86_64
|
||||
mem_limit: 500m
|
||||
cpus: 0.5
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: always
|
||||
expose:
|
||||
|
|
@ -108,6 +78,11 @@ services:
|
|||
environment:
|
||||
MYSQL_ROOT_PASSWORD: $MYSQL_PASSWORD
|
||||
MYSQL_DATABASE: oncall_hobby
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
healthcheck:
|
||||
test: "mysql -uroot -p$MYSQL_PASSWORD oncall_hobby -e 'select 1'"
|
||||
timeout: 20s
|
||||
|
|
@ -115,24 +90,30 @@ services:
|
|||
|
||||
redis:
|
||||
image: redis
|
||||
mem_limit: 100m
|
||||
cpus: 0.1
|
||||
restart: always
|
||||
expose:
|
||||
- 6379
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 100m
|
||||
cpus: '0.1'
|
||||
|
||||
rabbitmq:
|
||||
image: "rabbitmq:3.7.15-management"
|
||||
restart: always
|
||||
hostname: rabbitmq
|
||||
mem_limit: 1000m
|
||||
cpus: 0.5
|
||||
volumes:
|
||||
- rabbitmqdata:/var/lib/rabbitmq
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "rabbitmq"
|
||||
RABBITMQ_DEFAULT_PASS: $RABBITMQ_PASSWORD
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1000m
|
||||
cpus: '0.5'
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 30s
|
||||
|
|
@ -152,19 +133,22 @@ services:
|
|||
grafana:
|
||||
image: "grafana/grafana:9.0.0-beta3"
|
||||
restart: always
|
||||
mem_limit: 500m
|
||||
ports:
|
||||
- 3000:3000
|
||||
cpus: 0.5
|
||||
- "3000:3000"
|
||||
environment:
|
||||
GF_DATABASE_TYPE: mysql
|
||||
GF_DATABASE_HOST: ${MYSQL_HOST:-mysql}
|
||||
GF_DATABASE_USER: ${MYSQL_USER:-root}
|
||||
GF_DATABASE_PASSWORD: ${MYSQL_PASSWORD:?err}
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:?err}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
|
||||
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
|
||||
GF_INSTALL_PLUGINS: grafana-oncall-app
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500m
|
||||
cpus: '0.5'
|
||||
depends_on:
|
||||
mysql_to_create_grafana_db:
|
||||
condition: service_completed_successfully
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ 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": "https://ghostbusters.slack.com/archives/C1H9RESGA/p135854651500008",
|
||||
"telegram": "https://t.me/c/5354/1234?thread=1234"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class AlertGroupEmailRenderer(AlertGroupBaseRenderer):
|
|||
content = render_to_string(
|
||||
"email_notification.html",
|
||||
{
|
||||
"url": self.alert_group.permalink or self.alert_group.web_link,
|
||||
"url": self.alert_group.slack_permalink or self.alert_group.web_link,
|
||||
"title": str_or_backup(templated_alert.title, title_fallback),
|
||||
"message": str_or_backup(templated_alert.message, ""), # not render message it all if smth go wrong
|
||||
"amixr_team": self.alert_group.channel.organization,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ class AlertGroupSmsRenderer(AlertGroupBaseRenderer):
|
|||
def render(self):
|
||||
templated_alert = self.alert_renderer.templated_alert
|
||||
title = str_or_backup(templated_alert.title, DEFAULT_BACKUP_TITLE)
|
||||
if self.alert_group.channel.organization.slack_team_identity and (permalink := self.alert_group.permalink):
|
||||
if self.alert_group.channel.organization.slack_team_identity and (
|
||||
permalink := self.alert_group.slack_permalink
|
||||
):
|
||||
incident_link = permalink
|
||||
else:
|
||||
incident_link = self.alert_group.web_link
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -396,10 +401,36 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def permalink(self):
|
||||
def slack_permalink(self):
|
||||
if self.slack_message is not None:
|
||||
return self.slack_message.permalink
|
||||
|
||||
@property
|
||||
def telegram_permalink(self) -> typing.Optional[str]:
|
||||
"""
|
||||
This property will attempt to access an attribute, `prefetched_telegram_messages`, representing a list of
|
||||
prefetched telegram messages. If this attribute does not exist, it falls back to performing a query.
|
||||
|
||||
See `apps.public_api.serializers.incidents.IncidentSerializer.PREFETCH_RELATED` as an example.
|
||||
"""
|
||||
from apps.telegram.models.message import TelegramMessage
|
||||
|
||||
if hasattr(self, "prefetched_telegram_messages"):
|
||||
return self.prefetched_telegram_messages[0].link if self.prefetched_telegram_messages else None
|
||||
|
||||
main_telegram_message = self.telegram_messages.filter(
|
||||
chat_id__startswith="-", message_type=TelegramMessage.ALERT_GROUP_MESSAGE
|
||||
).first()
|
||||
|
||||
return main_telegram_message.link if main_telegram_message else None
|
||||
|
||||
@property
|
||||
def permalinks(self) -> Permalinks:
|
||||
return {
|
||||
"slack": self.slack_permalink,
|
||||
"telegram": self.telegram_permalink,
|
||||
}
|
||||
|
||||
@property
|
||||
def web_link(self):
|
||||
return urljoin(self.channel.organization.web_link, f"?page=incident&id={self.public_primary_key}")
|
||||
|
|
@ -978,18 +1009,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 +1063,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 +1113,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 +1211,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 +1244,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 +1288,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 +1365,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()
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ def notify_ical_schedule_shift(schedule_pk):
|
|||
new_shifts = sorted(new_shifts, key=lambda shift: shift["start"])
|
||||
|
||||
if len(new_shifts) != 0:
|
||||
days_to_lookup = (new_shifts[-1]["end"].date() - now.date()).days
|
||||
days_to_lookup = (new_shifts[-1]["end"].date() - now.date()).days + 1
|
||||
days_to_lookup = max([days_to_lookup, MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT])
|
||||
else:
|
||||
days_to_lookup = MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.tasks.notify_ical_schedule_shift import notify_ical_schedule_shift
|
||||
from apps.schedules.models import OnCallScheduleICal
|
||||
|
|
@ -9,32 +14,35 @@ PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
|||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:t
|
||||
X-WR-TIMEZONE:Asia/Yekaterinburg
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Asia/Yekaterinburg
|
||||
X-LIC-LOCATION:Asia/Yekaterinburg
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0500
|
||||
TZOFFSETTO:+0500
|
||||
TZNAME:+05
|
||||
DTSTART:19700101T000000
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Asia/Yekaterinburg:20210124T130000
|
||||
DTEND;TZID=Asia/Yekaterinburg:20210124T220000
|
||||
RRULE:FREQ=DAILY
|
||||
DTSTAMP:20210127T143634Z
|
||||
UID:0i0af8p6p8vfampe3r1vkog0jg@google.com
|
||||
CREATED:20210127T143553Z
|
||||
DTSTART;VALUE=DATE:20211005
|
||||
DTEND;VALUE=DATE:20211012
|
||||
RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=7;BYDAY=WE
|
||||
DTSTAMP:20210930T125523Z
|
||||
UID:id1@google.com
|
||||
CREATED:20210928T202349Z
|
||||
DESCRIPTION:
|
||||
LAST-MODIFIED:20210127T143553Z
|
||||
LAST-MODIFIED:20210929T204751Z
|
||||
LOCATION:
|
||||
SEQUENCE:0
|
||||
SEQUENCE:1
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:@Bernard Desruisseaux
|
||||
TRANSP:OPAQUE
|
||||
SUMMARY:user1
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20210928
|
||||
DTEND;VALUE=DATE:20211005
|
||||
RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=7;BYDAY=WE
|
||||
DTSTAMP:20210930T125523Z
|
||||
UID:id2@google.com
|
||||
CREATED:20210928T202331Z
|
||||
DESCRIPTION:
|
||||
LAST-MODIFIED:20210929T204744Z
|
||||
LOCATION:
|
||||
SEQUENCE:2
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:user2
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
|
@ -61,3 +69,36 @@ def test_current_overrides_ical_schedule_is_none(
|
|||
|
||||
# this should not raise
|
||||
notify_ical_schedule_shift(ical_schedule.oncallschedule_ptr_id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_shift_notification_long_shifts(
|
||||
make_organization_and_user_with_slack_identities,
|
||||
make_schedule,
|
||||
make_user,
|
||||
):
|
||||
organization, _, _, _ = make_organization_and_user_with_slack_identities()
|
||||
make_user(organization=organization, username="user1")
|
||||
make_user(organization=organization, username="user2")
|
||||
|
||||
ical_schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleICal,
|
||||
name="test_ical_schedule",
|
||||
channel="channel",
|
||||
ical_url_primary="url",
|
||||
prev_ical_file_primary=ICAL_DATA,
|
||||
cached_ical_file_primary=ICAL_DATA,
|
||||
prev_ical_file_overrides=None,
|
||||
cached_ical_file_overrides=None,
|
||||
)
|
||||
|
||||
with patch.object(timezone, "datetime", Mock(wraps=timezone.datetime)) as mock_tz_datetime:
|
||||
mock_tz_datetime.now.return_value = datetime(2021, 9, 29, 12, 0, tzinfo=pytz.UTC)
|
||||
with patch("apps.slack.slack_client.SlackClientWithErrorHandling.api_call") as mock_slack_api_call:
|
||||
notify_ical_schedule_shift(ical_schedule.oncallschedule_ptr_id)
|
||||
|
||||
slack_blocks = mock_slack_api_call.call_args_list[0][1]["blocks"]
|
||||
notification = slack_blocks[0]["text"]["text"]
|
||||
assert "*New on-call shift:*\nuser2" in notification
|
||||
assert "*Next on-call shift:*\nuser1" in notification
|
||||
|
|
|
|||
|
|
@ -12,3 +12,11 @@ def test_request_outgoing_webhook_cannot_resolve_name():
|
|||
success, err = request_outgoing_webhook("http://something.something/webhook", "GET")
|
||||
assert success is False
|
||||
assert err == "Cannot resolve name in url"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_request_outgoing_webhook_resolve_name_without_port():
|
||||
with patch("apps.alerts.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "127.0.0.1"
|
||||
request_outgoing_webhook("http://something.something:9000/webhook", "GET")
|
||||
assert mock_gethostbyname.call_args_list[0].args[0] == "something.something"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ def request_outgoing_webhook(webhook_url, http_request_type, post_kwargs={}) ->
|
|||
if not live_settings.DANGEROUS_WEBHOOKS_ENABLED:
|
||||
# Get the ip address of the webhook url and check if it belongs to the private network
|
||||
try:
|
||||
webhook_url_ip_address = socket.gethostbyname(parsed_url.netloc)
|
||||
webhook_url_ip_address = socket.gethostbyname(parsed_url.hostname)
|
||||
except socket.gaierror:
|
||||
return False, "Cannot resolve name in url"
|
||||
if not live_settings.DANGEROUS_WEBHOOKS_ENABLED:
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ class AlertGroupSerializer(AlertGroupListSerializer):
|
|||
fields = AlertGroupListSerializer.Meta.fields + [
|
||||
"alerts",
|
||||
"render_after_resolve_report_json",
|
||||
"permalink",
|
||||
"slack_permalink",
|
||||
"last_alert_at",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -377,3 +377,44 @@ def test_custom_button_action_permissions(
|
|||
response = client.post(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_custom_button_from_other_team_with_flag(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
make_custom_action,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
custom_button = make_custom_action(organization=organization, team=team)
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key})
|
||||
url = f"{url}?from_organization=true"
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_button_from_other_team_without_flag(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
make_custom_action,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
custom_button = make_custom_action(organization=organization, team=team)
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:custom_button-detail", kwargs={"pk": custom_button.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from rest_framework.serializers import ValidationError
|
|||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import EscalationPolicy
|
||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||
from apps.schedules.models import (
|
||||
CustomOnCallShift,
|
||||
OnCallSchedule,
|
||||
|
|
@ -742,6 +743,8 @@ def test_filter_events_final_schedule(
|
|||
request_date = start_date
|
||||
|
||||
user_a, user_b, user_c, user_d, user_e = (make_user_for_organization(organization, username=i) for i in "ABCDE")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
@ -837,7 +840,7 @@ def test_next_shifts_per_user(
|
|||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
organization, admin, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(
|
||||
|
|
@ -848,6 +851,8 @@ def test_next_shifts_per_user(
|
|||
|
||||
tomorrow = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timezone.timedelta(days=1)
|
||||
user_a, user_b, user_c, user_d = (make_user_for_organization(organization, username=i) for i in "ABCD")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
@ -860,16 +865,16 @@ def test_next_shifts_per_user(
|
|||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": tomorrow + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": tomorrow,
|
||||
"rotation_start": tomorrow + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(hours=duration),
|
||||
"priority_level": priority,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.users.add(user)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
|
||||
# override in the past: 17-18 / D
|
||||
# won't be listed, but user D will still be included in the response
|
||||
|
|
@ -896,10 +901,10 @@ def test_next_shifts_per_user(
|
|||
)
|
||||
override.add_rolling_users([[user_c]])
|
||||
|
||||
# final sdhedule: 7-12: B, 15-16: A, 16-17: B, 17-18: C (override), 18-20: C
|
||||
# final schedule: 7-12: B, 15-16: A, 16-17: B, 17-18: C (override), 18-20: C
|
||||
|
||||
url = reverse("api-internal:schedule-next-shifts-per-user", kwargs={"pk": schedule.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
response = client.get(url, format="json", **make_user_auth_headers(admin, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
expected = {
|
||||
|
|
@ -980,6 +985,8 @@ def test_merging_same_shift_events(
|
|||
user_a = make_user_for_organization(organization)
|
||||
user_b = make_user_for_organization(organization)
|
||||
user_c = make_user_for_organization(organization, role=Role.VIEWER)
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
data = {
|
||||
"start": start_date + timezone.timedelta(hours=10),
|
||||
|
|
@ -1401,3 +1408,54 @@ def test_schedule_mention_options_permissions(
|
|||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_from_other_team_with_flag(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
calendar_schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleCalendar,
|
||||
name="test_calendar_schedule",
|
||||
team=team,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key})
|
||||
url = f"{url}?from_organization=true"
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_schedule_from_other_team_without_flag(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
calendar_schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleCalendar,
|
||||
name="test_calendar_schedule",
|
||||
team=team,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from django.urls import reverse
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.models import OnCallScheduleCalendar
|
||||
from apps.user_management.models import Team
|
||||
from common.constants.role import Role
|
||||
|
||||
|
|
@ -105,28 +106,31 @@ def test_team_permissions_wrong_team_general(
|
|||
user = make_user(organization=organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
user.teams.add(team)
|
||||
user.current_team = team
|
||||
user.save(update_fields=["current_team"])
|
||||
|
||||
user_from_general_team = make_user(organization=organization)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
# escalation_chain = make_escalation_chain(organization)
|
||||
# schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
|
||||
# webhook = make_custom_action(organization)
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
|
||||
webhook = make_custom_action(organization)
|
||||
|
||||
for endpoint, instance in (
|
||||
("alertgroup", alert_group),
|
||||
# todo: implement team filtering for other resources
|
||||
# ("alert_receive_channel", alert_receive_channel),
|
||||
# ("escalation_chain", escalation_chain),
|
||||
# ("schedule", schedule),
|
||||
# ("custom_button", webhook),
|
||||
("alert_receive_channel", alert_receive_channel),
|
||||
("escalation_chain", escalation_chain),
|
||||
("schedule", schedule),
|
||||
("custom_button", webhook),
|
||||
("user", user_from_general_team),
|
||||
):
|
||||
client = APIClient()
|
||||
url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
|
@ -156,25 +160,30 @@ def test_team_permissions_wrong_team(
|
|||
user = make_user(organization=organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
team = make_team(organization)
|
||||
user.teams.add(team)
|
||||
|
||||
another_user = make_user(organization=organization)
|
||||
another_user.teams.add(team)
|
||||
another_user.current_team = team
|
||||
another_user.save(update_fields=["current_team"])
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization, team=team)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
# escalation_chain = make_escalation_chain(organization, team=team)
|
||||
# schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
# webhook = make_custom_action(organization, team=team)
|
||||
escalation_chain = make_escalation_chain(organization, team=team)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
webhook = make_custom_action(organization, team=team)
|
||||
|
||||
for endpoint, instance in (
|
||||
("alertgroup", alert_group),
|
||||
# todo: implement team filtering for other resources
|
||||
# ("alert_receive_channel", alert_receive_channel),
|
||||
# ("escalation_chain", escalation_chain),
|
||||
# ("schedule", schedule),
|
||||
# ("custom_button", webhook),
|
||||
("alert_receive_channel", alert_receive_channel),
|
||||
("escalation_chain", escalation_chain),
|
||||
("schedule", schedule),
|
||||
("custom_button", webhook),
|
||||
):
|
||||
client = APIClient()
|
||||
url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
|
@ -190,6 +199,12 @@ def test_team_permissions_wrong_team(
|
|||
},
|
||||
}
|
||||
|
||||
# Every user belongs to General team
|
||||
url = reverse(f"api-internal:user-detail", kwargs={"pk": another_user.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_permissions_not_in_team(
|
||||
|
|
@ -209,24 +224,29 @@ def test_team_permissions_not_in_team(
|
|||
user = make_user(organization=organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
another_user = make_user(organization=organization)
|
||||
another_user.teams.add(team)
|
||||
another_user.current_team = team
|
||||
another_user.save(update_fields=["current_team"])
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization, team=team)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
# escalation_chain = make_escalation_chain(organization, team=team)
|
||||
# schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
# webhook = make_custom_action(organization, team=team)
|
||||
escalation_chain = make_escalation_chain(organization, team=team)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
webhook = make_custom_action(organization, team=team)
|
||||
|
||||
for endpoint, instance in (
|
||||
("alertgroup", alert_group),
|
||||
# todo: implement team filtering for other resources
|
||||
# ("alert_receive_channel", alert_receive_channel),
|
||||
# ("escalation_chain", escalation_chain),
|
||||
# ("schedule", schedule),
|
||||
# ("custom_button", webhook),
|
||||
("alert_receive_channel", alert_receive_channel),
|
||||
("escalation_chain", escalation_chain),
|
||||
("schedule", schedule),
|
||||
("custom_button", webhook),
|
||||
):
|
||||
client = APIClient()
|
||||
url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
|
@ -234,6 +254,12 @@ def test_team_permissions_not_in_team(
|
|||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {"error_code": "wrong_team"}
|
||||
|
||||
# Every user belongs to General team
|
||||
url = reverse(f"api-internal:user-detail", kwargs={"pk": another_user.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_permissions_right_team(
|
||||
|
|
@ -253,28 +279,32 @@ def test_team_permissions_right_team(
|
|||
user = make_user(organization=organization)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
team = make_team(organization)
|
||||
|
||||
user.teams.add(team)
|
||||
user.current_team = team
|
||||
user.save(update_fields=["current_team"])
|
||||
|
||||
another_user = make_user(organization=organization)
|
||||
another_user.teams.add(team)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization, team=team)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
# escalation_chain = make_escalation_chain(organization, team=team)
|
||||
# schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
# webhook = make_custom_action(organization, team=team)
|
||||
escalation_chain = make_escalation_chain(organization, team=team)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar, team=team)
|
||||
webhook = make_custom_action(organization, team=team)
|
||||
|
||||
for endpoint, instance in (
|
||||
("alertgroup", alert_group),
|
||||
# todo: implement team filtering for other resources
|
||||
# ("alert_receive_channel", alert_receive_channel),
|
||||
# ("escalation_chain", escalation_chain),
|
||||
# ("schedule", schedule),
|
||||
# ("custom_button", webhook),
|
||||
("alert_receive_channel", alert_receive_channel),
|
||||
("escalation_chain", escalation_chain),
|
||||
("schedule", schedule),
|
||||
("custom_button", webhook),
|
||||
("user", another_user),
|
||||
):
|
||||
client = APIClient()
|
||||
url = reverse(f"api-internal:{endpoint}-detail", kwargs={"pk": instance.public_primary_key})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from common.api_helpers.mixins import (
|
|||
FilterSerializerMixin,
|
||||
PreviewTemplateMixin,
|
||||
PublicPrimaryKeyMixin,
|
||||
TeamFilteringMixin,
|
||||
UpdateSerializerMixin,
|
||||
)
|
||||
from common.exceptions import TeamCanNotBeChangedError, UnableToSendDemoAlert
|
||||
|
|
@ -58,6 +59,7 @@ class AlertReceiveChannelFilter(filters.FilterSet):
|
|||
|
||||
class AlertReceiveChannelView(
|
||||
PreviewTemplateMixin,
|
||||
TeamFilteringMixin,
|
||||
PublicPrimaryKeyMixin,
|
||||
FilterSerializerMixin,
|
||||
UpdateSerializerMixin,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission,
|
|||
from apps.api.serializers.custom_button import CustomButtonSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
|
||||
|
||||
class CustomButtonView(PublicPrimaryKeyMixin, ModelViewSet):
|
||||
class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, ActionPermission)
|
||||
action_permissions = {
|
||||
|
|
@ -36,7 +36,15 @@ class CustomButtonView(PublicPrimaryKeyMixin, ModelViewSet):
|
|||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
# Override this method because we want to get object from organization instead of concrete team.
|
||||
# get the object from the whole organization if there is a flag `get_from_organization=true`
|
||||
# otherwise get the object from the current team
|
||||
get_from_organization = self.request.query_params.get("from_organization", "false") == "true"
|
||||
if get_from_organization:
|
||||
return self.get_object_from_organization()
|
||||
return super().get_object()
|
||||
|
||||
def get_object_from_organization(self):
|
||||
# use this method to get the object from the whole organization instead of the current team
|
||||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
|
||||
|
|
@ -50,9 +58,6 @@ class CustomButtonView(PublicPrimaryKeyMixin, ModelViewSet):
|
|||
|
||||
return obj
|
||||
|
||||
def original_get_object(self):
|
||||
return super().get_object()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
write_resource_insight_log(
|
||||
|
|
@ -85,7 +90,7 @@ class CustomButtonView(PublicPrimaryKeyMixin, ModelViewSet):
|
|||
def action(self, request, pk):
|
||||
alert_group_id = request.query_params.get("alert_group", None)
|
||||
if alert_group_id is not None:
|
||||
custom_button = self.original_get_object()
|
||||
custom_button = self.get_object()
|
||||
try:
|
||||
alert_group = AlertGroup.unarchived_objects.get(
|
||||
public_primary_key=alert_group_id, channel=custom_button.alert_receive_channel
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission,
|
|||
from apps.api.serializers.escalation_chain import EscalationChainListSerializer, EscalationChainSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import ListSerializerMixin, PublicPrimaryKeyMixin
|
||||
from common.api_helpers.mixins import ListSerializerMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
from common.insight_log import EntityEvent, write_resource_insight_log
|
||||
|
||||
|
||||
class EscalationChainViewSet(PublicPrimaryKeyMixin, ListSerializerMixin, viewsets.ModelViewSet):
|
||||
class EscalationChainViewSet(TeamFilteringMixin, PublicPrimaryKeyMixin, ListSerializerMixin, viewsets.ModelViewSet):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, ActionPermission)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from common.api_helpers.mixins import (
|
|||
CreateSerializerMixin,
|
||||
PublicPrimaryKeyMixin,
|
||||
ShortSerializerMixin,
|
||||
TeamFilteringMixin,
|
||||
UpdateSerializerMixin,
|
||||
)
|
||||
from common.api_helpers.utils import create_engine_url, get_date_range_from_request
|
||||
|
|
@ -43,7 +44,12 @@ EVENTS_FILTER_BY_FINAL = "final"
|
|||
|
||||
|
||||
class ScheduleView(
|
||||
PublicPrimaryKeyMixin, ShortSerializerMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet
|
||||
TeamFilteringMixin,
|
||||
PublicPrimaryKeyMixin,
|
||||
ShortSerializerMixin,
|
||||
CreateSerializerMixin,
|
||||
UpdateSerializerMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, ActionPermission)
|
||||
|
|
@ -124,28 +130,6 @@ class ScheduleView(
|
|||
queryset = self.serializer_class.setup_eager_loading(queryset)
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
# Override this method because we want to get object from organization instead of concrete team.
|
||||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
queryset = organization.oncall_schedules.filter(
|
||||
public_primary_key=pk,
|
||||
)
|
||||
queryset = self._annotate_queryset(queryset)
|
||||
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
|
||||
return obj
|
||||
|
||||
def original_get_object(self):
|
||||
return super().get_object()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
write_resource_insight_log(instance=serializer.instance, author=self.request.user, event=EntityEvent.CREATED)
|
||||
|
|
@ -178,6 +162,33 @@ class ScheduleView(
|
|||
if instance.user_group is not None:
|
||||
update_slack_user_group_for_schedules.apply_async((instance.user_group.pk,))
|
||||
|
||||
def get_object(self):
|
||||
# get the object from the whole organization if there is a flag `get_from_organization=true`
|
||||
# otherwise get the object from the current team
|
||||
get_from_organization = self.request.query_params.get("from_organization", "false") == "true"
|
||||
if get_from_organization:
|
||||
return self.get_object_from_organization()
|
||||
return super().get_object()
|
||||
|
||||
def get_object_from_organization(self):
|
||||
# use this method to get the object from the whole organization instead of the current team
|
||||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
queryset = organization.oncall_schedules.filter(
|
||||
public_primary_key=pk,
|
||||
)
|
||||
queryset = self._annotate_queryset(queryset)
|
||||
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
|
||||
return obj
|
||||
|
||||
def get_request_timezone(self):
|
||||
user_tz = self.request.query_params.get("user_tz", "UTC")
|
||||
try:
|
||||
|
|
@ -203,7 +214,7 @@ class ScheduleView(
|
|||
with_empty = self.request.query_params.get("with_empty", False) == "true"
|
||||
with_gap = self.request.query_params.get("with_gap", False) == "true"
|
||||
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
events = schedule.filter_events(user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)
|
||||
|
||||
slack_channel = (
|
||||
|
|
@ -235,7 +246,7 @@ class ScheduleView(
|
|||
raise BadRequest(detail="Invalid type value")
|
||||
resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL
|
||||
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
|
||||
if filter_by is not None and filter_by != EVENTS_FILTER_BY_FINAL:
|
||||
filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES
|
||||
|
|
@ -259,7 +270,7 @@ class ScheduleView(
|
|||
user_tz, _ = self.get_request_timezone()
|
||||
now = timezone.now()
|
||||
starting_date = now.date()
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
events = schedule.final_events(user_tz, starting_date, days=30)
|
||||
|
||||
users = {u: None for u in schedule.related_users()}
|
||||
|
|
@ -274,7 +285,7 @@ class ScheduleView(
|
|||
@action(detail=True, methods=["get"])
|
||||
def related_escalation_chains(self, request, pk):
|
||||
"""Return escalation chains associated to schedule."""
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
escalation_chains = EscalationChain.objects.filter(escalation_policies__notify_schedule=schedule).distinct()
|
||||
|
||||
result = [{"name": e.name, "pk": e.public_primary_key} for e in escalation_chains]
|
||||
|
|
@ -290,7 +301,7 @@ class ScheduleView(
|
|||
|
||||
@action(detail=True, methods=["post"])
|
||||
def reload_ical(self, request, pk):
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
schedule.drop_cached_ical()
|
||||
schedule.check_empty_shifts_for_next_week()
|
||||
schedule.check_gaps_for_next_week()
|
||||
|
|
@ -302,7 +313,7 @@ class ScheduleView(
|
|||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def export_token(self, request, pk):
|
||||
schedule = self.original_get_object()
|
||||
schedule = self.get_object()
|
||||
|
||||
if self.request.method == "GET":
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from apps.api.permissions import (
|
|||
IsAdminOrEditor,
|
||||
IsOwnerOrAdmin,
|
||||
)
|
||||
from apps.api.serializers.team import TeamSerializer
|
||||
from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer
|
||||
from apps.auth_token.auth import (
|
||||
MobileAppAuthTokenAuthentication,
|
||||
|
|
@ -39,7 +40,7 @@ from apps.telegram.client import TelegramClient
|
|||
from apps.telegram.models import TelegramVerificationCode
|
||||
from apps.twilioapp.phone_manager import PhoneManager
|
||||
from apps.twilioapp.twilio_client import twilio_client
|
||||
from apps.user_management.models import User
|
||||
from apps.user_management.models import Team, User
|
||||
from common.api_helpers.exceptions import Conflict
|
||||
from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin
|
||||
from common.api_helpers.paginators import HundredPageSizePaginator
|
||||
|
|
@ -228,7 +229,10 @@ class UserView(
|
|||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
context = {"request": self.request, "format": self.format_kwarg, "view": self}
|
||||
instance = self.get_object()
|
||||
try:
|
||||
instance = self.get_object()
|
||||
except NotFound:
|
||||
return self.wrong_team_response()
|
||||
|
||||
if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED:
|
||||
from apps.oss_installation.models import CloudConnector, CloudUserIdentity
|
||||
|
|
@ -243,6 +247,28 @@ class UserView(
|
|||
serializer = self.get_serializer(instance, context=context)
|
||||
return Response(serializer.data)
|
||||
|
||||
def wrong_team_response(self):
|
||||
"""
|
||||
This method returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}}.
|
||||
Used in case if a requested instance doesn't belong to user's current_team.
|
||||
Used instead of TeamFilteringMixin because of m2m teams field (mixin doesn't work correctly with this)
|
||||
and overridden retrieve method in UserView.
|
||||
"""
|
||||
queryset = User.objects.filter(organization=self.request.user.organization).order_by("id")
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
try:
|
||||
queryset.get(public_primary_key=self.kwargs["pk"])
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
general_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None)
|
||||
|
||||
return Response(
|
||||
data={"error_code": "wrong_team", "owner_team": TeamSerializer(general_team).data},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
def current(self, request):
|
||||
serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk))
|
||||
return Response(serializer.data)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from django.db.models import Prefetch
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.telegram.models.message import TelegramMessage
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
|
||||
|
||||
|
|
@ -14,8 +16,15 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
title = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
|
||||
SELECT_RELATED = ["channel", "channel_filter"]
|
||||
PREFETCH_RELATED = ["alerts"]
|
||||
SELECT_RELATED = ["channel", "channel_filter", "slack_message"]
|
||||
PREFETCH_RELATED = [
|
||||
"alerts",
|
||||
Prefetch(
|
||||
"telegram_messages",
|
||||
TelegramMessage.objects.filter(chat_id__startswith="-", message_type=TelegramMessage.ALERT_GROUP_MESSAGE),
|
||||
to_attr="prefetched_telegram_messages",
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = AlertGroup
|
||||
|
|
@ -29,6 +38,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
"resolved_at",
|
||||
"acknowledged_at",
|
||||
"title",
|
||||
"permalinks",
|
||||
]
|
||||
|
||||
def get_alerts_count(self, obj):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
|
@ -38,6 +39,10 @@ def construct_expected_response_from_incidents(incidents):
|
|||
"resolved_at": resolved_at,
|
||||
"acknowledged_at": acknowledged_at,
|
||||
"title": None,
|
||||
"permalinks": {
|
||||
"slack": None,
|
||||
"telegram": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
expected_response = {"count": incidents.count(), "next": None, "previous": None, "results": results}
|
||||
|
|
@ -183,6 +188,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):
|
||||
|
|
|
|||
|
|
@ -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,19 +175,18 @@ 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,
|
||||
}
|
||||
)
|
||||
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)
|
||||
if start < end:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from calendar import monthrange
|
|||
from uuid import uuid4
|
||||
|
||||
import pytz
|
||||
from dateutil import relativedelta
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
|
|
@ -353,6 +354,13 @@ class CustomOnCallShift(models.Model):
|
|||
ONE_DAY = 1
|
||||
ONE_HOUR = 1
|
||||
|
||||
def add_months(year, month, months_add):
|
||||
"""
|
||||
Utility method for month calculation. E.g. (2022, 12) + 1 month = (2023, 1)
|
||||
"""
|
||||
dt = timezone.datetime.min.replace(year=year, month=month) + relativedelta.relativedelta(months=months_add)
|
||||
return dt.year, dt.month
|
||||
|
||||
current_event = Event.from_ical(event_ical)
|
||||
# take shift interval, not event interval. For rolling_users shift it is not the same.
|
||||
interval = self.interval or 1
|
||||
|
|
@ -385,7 +393,8 @@ class CustomOnCallShift(models.Model):
|
|||
days_for_next_event = DAYS_IN_A_MONTH - current_event_start.day + ONE_DAY
|
||||
# count next event start date with respect to event interval
|
||||
for i in range(1, interval):
|
||||
next_month_days = monthrange(current_event_start.year, current_event_start.month + i)[1]
|
||||
year, month = add_months(current_event_start.year, current_event_start.month, i)
|
||||
next_month_days = monthrange(year, month)[1]
|
||||
days_for_next_event += next_month_days
|
||||
next_event_start = current_event_start + timezone.timedelta(days=days_for_next_event)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -645,6 +654,7 @@ class OnCallScheduleWeb(OnCallSchedule):
|
|||
for g in rolling_groups
|
||||
if g is not None
|
||||
),
|
||||
set(),
|
||||
)
|
||||
return users
|
||||
|
||||
|
|
|
|||
|
|
@ -354,11 +354,11 @@ def test_rolling_users_event_with_interval_monthly(
|
|||
user_2 = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
|
||||
start_date = timezone.now().replace(day=1, microsecond=0)
|
||||
days_for_next_month_1 = monthrange(start_date.year, start_date.month)[1]
|
||||
days_for_next_month_2 = monthrange(start_date.year, start_date.month + 1)[1] + days_for_next_month_1
|
||||
days_for_next_month_3 = monthrange(start_date.year, start_date.month + 2)[1] + days_for_next_month_2
|
||||
days_for_next_month_4 = monthrange(start_date.year, start_date.month + 3)[1] + days_for_next_month_3
|
||||
start_date = timezone.datetime(year=2022, month=10, day=1, hour=10, minute=30)
|
||||
days_for_next_month_1 = monthrange(2022, 10)[1]
|
||||
days_for_next_month_2 = monthrange(2022, 11)[1] + days_for_next_month_1
|
||||
days_for_next_month_3 = monthrange(2022, 12)[1] + days_for_next_month_2
|
||||
days_for_next_month_4 = monthrange(2023, 1)[1] + days_for_next_month_3
|
||||
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
|
|
@ -718,19 +718,19 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly(
|
|||
user_3 = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
now = timezone.now().replace(day=1, microsecond=0)
|
||||
days_in_curr_month = monthrange(now.year, now.month)[1]
|
||||
days_in_next_month = monthrange(now.year, now.month + 1)[1]
|
||||
start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30)
|
||||
days_in_curr_month = monthrange(2022, 12)[1]
|
||||
days_in_next_month = monthrange(2023, 1)[1]
|
||||
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
"start": now,
|
||||
"week_start": now.weekday(),
|
||||
"rotation_start": now + timezone.timedelta(days=days_in_curr_month - 1, hours=1),
|
||||
"start": start_date,
|
||||
"week_start": start_date.weekday(),
|
||||
"rotation_start": start_date + timezone.timedelta(days=days_in_curr_month - 1, hours=1),
|
||||
"duration": timezone.timedelta(seconds=1800),
|
||||
"frequency": CustomOnCallShift.FREQUENCY_MONTHLY,
|
||||
"schedule": schedule,
|
||||
"until": now + timezone.timedelta(days=days_in_curr_month + days_in_next_month + 10, minutes=1),
|
||||
"until": start_date + timezone.timedelta(days=days_in_curr_month + days_in_next_month + 10, minutes=1),
|
||||
}
|
||||
rolling_users = [[user_1], [user_2], [user_3]]
|
||||
on_call_shift = make_on_call_shift(
|
||||
|
|
@ -738,7 +738,7 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly(
|
|||
)
|
||||
on_call_shift.add_rolling_users(rolling_users)
|
||||
|
||||
date = now + timezone.timedelta(minutes=5)
|
||||
date = start_date + timezone.timedelta(minutes=5)
|
||||
# rotation starts from user_2, because user_1 started earlier than rotation start date
|
||||
user_2_on_call_dates = [date + timezone.timedelta(days=days_in_curr_month)]
|
||||
user_3_on_call_dates = [date + timezone.timedelta(days=days_in_curr_month + days_in_next_month)]
|
||||
|
|
@ -774,9 +774,9 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly_by_monthday(
|
|||
user_3 = make_user_for_organization(organization)
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
start_date = timezone.now().replace(day=1, microsecond=0)
|
||||
days_in_curr_month = monthrange(start_date.year, start_date.month)[1]
|
||||
days_in_next_month = monthrange(start_date.year, start_date.month + 1)[1]
|
||||
start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30)
|
||||
days_in_curr_month = monthrange(2022, 12)[1]
|
||||
days_in_next_month = monthrange(2023, 1)[1]
|
||||
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical
|
||||
from apps.schedules.ical_utils import (
|
||||
list_of_oncall_shifts_from_ical,
|
||||
list_users_to_notify_from_ical,
|
||||
parse_event_uid,
|
||||
users_in_ical,
|
||||
)
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar
|
||||
from common.constants.role import Role
|
||||
|
||||
|
|
@ -63,6 +70,29 @@ def test_list_users_to_notify_from_ical_viewers_inclusion(
|
|||
assert set(users_on_call) == {user}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ical):
|
||||
calendar = get_ical("calendar_with_all_day_event.ics")
|
||||
organization = make_organization()
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
|
||||
schedule.cached_ical_file_primary = calendar.to_ical()
|
||||
|
||||
day_to_check_iso = "2021-01-27T15:27:14.448059+00:00"
|
||||
parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC)
|
||||
requested_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date()
|
||||
shifts = list_of_oncall_shifts_from_ical(schedule, requested_date, days=3, with_empty_shifts=True)
|
||||
assert len(shifts) == 4
|
||||
for s in shifts:
|
||||
start = s["start"].date() if isinstance(s["start"], datetime.datetime) else s["start"]
|
||||
end = s["end"].date() if isinstance(s["end"], datetime.datetime) else s["end"]
|
||||
# event started in the given period, or ended in that period, or is happening during the period
|
||||
assert (
|
||||
requested_date <= start <= requested_date + timezone.timedelta(days=3)
|
||||
or requested_date <= end <= requested_date + timezone.timedelta(days=3)
|
||||
or start <= requested_date <= end
|
||||
)
|
||||
|
||||
|
||||
def test_parse_event_uid_v1():
|
||||
uuid = uuid4()
|
||||
event_uid = f"amixr-{uuid}-U1-E2-S1"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb
|
||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb
|
||||
from common.constants.role import Role
|
||||
|
||||
|
||||
|
|
@ -225,6 +229,35 @@ 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)
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
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()
|
||||
|
|
@ -238,6 +271,8 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma
|
|||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
user_a, user_b, user_c, user_d, user_e = (make_user_for_organization(organization, username=i) for i in "ABCDE")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
@ -337,6 +372,8 @@ def test_final_schedule_splitting_events(
|
|||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
@ -404,6 +441,8 @@ def test_final_schedule_splitting_same_time_events(
|
|||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
@ -712,6 +751,19 @@ def test_preview_override_shift(make_organization, make_user_for_organization, m
|
|||
assert schedule._ical_file_overrides == schedule_overrides_ical
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_schedule_related_users_empty_schedule(make_organization, make_schedule):
|
||||
organization = make_organization()
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
users = schedule.related_users()
|
||||
assert users == set()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_schedule_related_users(make_organization, make_user_for_organization, make_on_call_shift, make_schedule):
|
||||
organization = make_organization()
|
||||
|
|
@ -725,6 +777,8 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m
|
|||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
user_a, _, _, user_d, user_e = (make_user_for_organization(organization, username=i) for i in "ABCDE")
|
||||
# clear users pks <-> organization cache (persisting between tests)
|
||||
memoized_users_in_ical.cache_clear()
|
||||
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (hs)
|
||||
|
|
|
|||
|
|
@ -354,9 +354,9 @@ class SelectAttachGroupStep(
|
|||
f"attached incidents ({attached_incidents.count()}):\n"
|
||||
)
|
||||
for dependent_alert in attached_incidents:
|
||||
if dependent_alert.permalink:
|
||||
if dependent_alert.slack_permalink:
|
||||
dependent_alert_text = (
|
||||
f"\n<{dependent_alert.permalink}|{dependent_alert.long_verbose_name_without_formatting}>"
|
||||
f"\n<{dependent_alert.slack_permalink}|{dependent_alert.long_verbose_name_without_formatting}>"
|
||||
)
|
||||
else:
|
||||
dependent_alert_text = f"\n{dependent_alert.long_verbose_name}"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slack_client.exceptions import SlackAPIException
|
||||
from apps.user_management.models import User
|
||||
from common.api_helpers.utils import create_engine_url
|
||||
|
||||
from .step_mixins import CheckAlertIsUnarchivedMixin
|
||||
|
|
@ -107,10 +108,18 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
|
|||
channel_id=channel_id,
|
||||
)
|
||||
alert_group = slack_message.get_alert_group()
|
||||
author_slack_user_identity = SlackUserIdentity.objects.get(
|
||||
slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity
|
||||
)
|
||||
author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity)
|
||||
try:
|
||||
author_slack_user_identity = SlackUserIdentity.objects.get(
|
||||
slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity
|
||||
)
|
||||
author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity)
|
||||
except (SlackUserIdentity.DoesNotExist, User.DoesNotExist):
|
||||
warning_text = (
|
||||
"Unable to add this message to resolution note: could not find corresponding "
|
||||
"OnCall user for message author: {}".format(payload["message"]["user"])
|
||||
)
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
resolution_note_slack_message = ResolutionNoteSlackMessage(
|
||||
alert_group=alert_group,
|
||||
user=author_user,
|
||||
|
|
@ -121,6 +130,7 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
|
|||
ts=message_ts,
|
||||
permalink=permalink,
|
||||
)
|
||||
|
||||
resolution_note_slack_message.added_to_resolution_note = True
|
||||
resolution_note_slack_message.save()
|
||||
resolution_note = resolution_note_slack_message.get_resolution_note()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ is_able_to_autoresolve = True
|
|||
is_demo_alert_enabled = True
|
||||
|
||||
description = """
|
||||
Alerts from Grafana Alertmanager are automatically routed to this integration."
|
||||
Alerts from Grafana Alertmanager are automatically routed to this integration.
|
||||
{% for dict_item in grafana_alerting_entities %}
|
||||
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
|
||||
to open contact point, and
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ is_able_to_autoresolve = True
|
|||
is_demo_alert_enabled = True
|
||||
|
||||
description = """ \
|
||||
Alerts from Grafana Alertmanager are automatically routed to this integration."
|
||||
Alerts from Grafana Alertmanager are automatically routed to this integration.
|
||||
{% for dict_item in grafana_alerting_entities %}
|
||||
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
|
||||
to open contact point, and
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ CELERY_BROKER_URL = (
|
|||
f"{RABBITMQ_PROTOCOL}://{RABBITMQ_USERNAME}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
|
||||
)
|
||||
|
||||
REDIS_USERNAME = os.environ.get("REDIS_USERNAME", "")
|
||||
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD")
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST")
|
||||
REDIS_PORT = os.environ.get("REDIS_PORT", "6379")
|
||||
REDIS_URI = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}"
|
||||
REDIS_PROTOCOL = os.environ.get("REDIS_PROTOCOL", "redis")
|
||||
REDIS_URI = f"{REDIS_PROTOCOL}://{REDIS_USERNAME}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
|
|
|
|||
1
grafana-plugin/.nvmrc
Normal file
1
grafana-plugin/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
14.17.0
|
||||
|
|
@ -1,67 +1,110 @@
|
|||
# Change Log
|
||||
|
||||
## v1.0.39 (2022-10-03)
|
||||
|
||||
- Fix issue in v1.0.38 blocking the creation of schedules and webhooks in the UI
|
||||
|
||||
## v1.0.38 (2022-09-30)
|
||||
|
||||
- Fix exception handling for adding resolution notes when slack and oncall users are out of sync.
|
||||
- Fix all day events showing as having gaps in slack notifications
|
||||
- Improve plugin configuration error message readability
|
||||
- Add `telegram` key to `permalinks` property in `AlertGroup` public API response schema
|
||||
|
||||
## v1.0.37 (2022-09-21)
|
||||
|
||||
- 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 +112,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 +131,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.
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
|
||||
return (
|
||||
<DefaultPageLayout {...props}>
|
||||
<GrafanaTeamSelect />
|
||||
<GrafanaTeamSelect currentPage={page} />
|
||||
<Page {...props} path={pathWithoutLeadingSlash} />
|
||||
</DefaultPageLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
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);
|
||||
background: var(--background-primary);
|
||||
border: var(--border-weak);
|
||||
box-shadow: var(--shadows-z3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const Modal: FC<PropsWithChildren<ModalProps>> = (props) => {
|
|||
|
||||
return (
|
||||
<ReactModal
|
||||
shouldCloseOnOverlayClick={false}
|
||||
style={{
|
||||
overlay: {},
|
||||
content: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { PageErrorData } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
|
||||
export function initErrorDataState(): Partial<PageErrorData> {
|
||||
return { isWrongTeamError: false, wrongTeamNoPermissions: false };
|
||||
}
|
||||
|
||||
export function getWrongTeamResponseInfo({ response }): Partial<PageErrorData> {
|
||||
if (response) {
|
||||
if (response.status === 404) {
|
||||
return { isNotFoundError: true };
|
||||
} else if (response.status === 403 && response.data.error_code === 'wrong_team') {
|
||||
let res = response.data;
|
||||
if (res.owner_team) {
|
||||
return { isWrongTeamError: true, switchToTeam: { name: res.owner_team.name, id: res.owner_team.id } };
|
||||
} else {
|
||||
return { isWrongTeamError: true, wrongTeamNoPermissions: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isNotFoundError: true };
|
||||
}
|
||||
|
|
@ -13,7 +13,3 @@
|
|||
margin-right: 4px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.return-to-list {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
|
@ -1,30 +1,59 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { Button, VerticalGroup, Icon } from '@grafana/ui';
|
||||
import { Button, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { PropTypes } from 'mobx-react';
|
||||
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import { ChangeTeamIcon } from 'icons';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openWarningNotification } from 'utils';
|
||||
|
||||
import styles from './WrongTeamStub.module.css';
|
||||
import styles from './PageErrorHandlingWrapper.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export interface WrongTeamStubProps {
|
||||
className?: string;
|
||||
objectName: string;
|
||||
pageName: string;
|
||||
currentTeam?: string;
|
||||
switchToTeam?: { name: string; id: string };
|
||||
wrongTeamNoPermissions?: boolean;
|
||||
export interface PageBaseState {
|
||||
errorData: PageErrorData;
|
||||
}
|
||||
|
||||
const WrongTeamStub: FC<WrongTeamStubProps> = (props) => {
|
||||
export interface PageErrorData {
|
||||
isNotFoundError?: boolean;
|
||||
isWrongTeamError?: boolean;
|
||||
wrongTeamNoPermissions?: boolean;
|
||||
switchToTeam?: { name: string; id: string };
|
||||
}
|
||||
|
||||
export default function PageErrorHandlingWrapper({
|
||||
errorData,
|
||||
objectName,
|
||||
pageName,
|
||||
itemNotFoundMessage,
|
||||
children,
|
||||
}: {
|
||||
errorData: PageErrorData;
|
||||
objectName: string;
|
||||
pageName: string;
|
||||
itemNotFoundMessage?: string;
|
||||
children: () => JSX.Element;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const { isWrongTeamError, isNotFoundError } = errorData;
|
||||
if (!isWrongTeamError && isNotFoundError && itemNotFoundMessage) {
|
||||
openWarningNotification(itemNotFoundMessage);
|
||||
}
|
||||
}, [errorData.isNotFoundError]);
|
||||
|
||||
const store = useStore();
|
||||
const { objectName, pageName, currentTeam, switchToTeam, className, wrongTeamNoPermissions } = props;
|
||||
|
||||
if (!errorData.isWrongTeamError) {return children();}
|
||||
|
||||
const currentTeamId = store.userStore.currentUser?.current_team;
|
||||
const currentTeam = store.grafanaTeamStore.items[currentTeamId]?.name;
|
||||
|
||||
const { switchToTeam, wrongTeamNoPermissions } = errorData;
|
||||
|
||||
const onTeamChange = async (teamId: GrafanaTeam['id']) => {
|
||||
await store.userStore.updateCurrentUser({ current_team: teamId });
|
||||
|
|
@ -57,12 +86,10 @@ const WrongTeamStub: FC<WrongTeamStubProps> = (props) => {
|
|||
Change the team
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary" className={cx('return-to-list')}>
|
||||
<Text type="secondary">
|
||||
Or return to the <PluginLink query={{ page: pageName }}>{objectName} list</PluginLink> for team {currentTeam}
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WrongTeamStub;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ interface PluginLinkProps extends LocationUpdate {
|
|||
disabled?: boolean;
|
||||
className?: string;
|
||||
wrap?: boolean;
|
||||
children: any
|
||||
children: any;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
|
|
|||
|
|
@ -14,14 +14,11 @@ import GSelect from 'containers/GSelect/GSelect';
|
|||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
import UserTooltip from 'containers/UserTooltip/UserTooltip';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { ActionDTO } from 'models/action';
|
||||
import { prepareEscalationPolicy } from 'models/escalation_policy/escalation_policy.helpers';
|
||||
import {
|
||||
EscalationPolicy as EscalationPolicyType,
|
||||
EscalationPolicyOption,
|
||||
} from 'models/escalation_policy/escalation_policy.types';
|
||||
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
|
||||
import { User, UserRole } from 'models/user/user.types';
|
||||
import { WaitDelay } from 'models/wait_delay';
|
||||
import { SelectOption } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
|
|
@ -272,15 +269,15 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
|
|||
|
||||
return (
|
||||
<WithPermissionControl key="notify_schedule" disableByPaywall userAction={UserAction.UpdateEscalationPolicies}>
|
||||
<RemoteSelect
|
||||
showSearch={false}
|
||||
<GSelect
|
||||
modelName="scheduleStore"
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Schedule"
|
||||
className={cx('select', 'control')}
|
||||
value={notify_schedule}
|
||||
valueField="id"
|
||||
onChange={this._getOnChangeHandler('notify_schedule')}
|
||||
href={'/schedules/?short=true'}
|
||||
fieldToShow="name"
|
||||
placeholder="Select Schedule"
|
||||
fromOrganization
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
);
|
||||
|
|
@ -319,6 +316,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
|
|||
className={cx('select', 'control')}
|
||||
value={custom_button_trigger}
|
||||
onChange={this._getOnChangeHandler('custom_button_trigger')}
|
||||
fromOrganization
|
||||
/>
|
||||
</WithPermissionControl>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,17 +4,14 @@
|
|||
|
||||
.root table {
|
||||
width: 100%;
|
||||
background: #22252b;
|
||||
}
|
||||
|
||||
.root tr {
|
||||
border-bottom: 1px solid #181b1f;
|
||||
height: 60px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.root tr:hover {
|
||||
/* background: var(--secondary-background); */
|
||||
background: rgba(63, 62, 62, 0.45);
|
||||
}
|
||||
|
||||
.root th:first-child {
|
||||
|
|
@ -33,6 +30,7 @@
|
|||
|
||||
.expand-icon {
|
||||
padding: 10px;
|
||||
color: var(--primary-text-color);
|
||||
pointer-events: none;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const GTable: FC<Props> = (props) => {
|
|||
<VerticalGroup justify="flex-end">
|
||||
<Table
|
||||
rowKey={rowKey}
|
||||
className={cx('root', className)}
|
||||
className={cx('root', 'filter-table', className)}
|
||||
columns={columns}
|
||||
data={data}
|
||||
expandable={expandable}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const Text: TextInterface = (props) => {
|
|||
clearBeforeEdit = false,
|
||||
hidden = false,
|
||||
editModalTitle = 'New value',
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(false);
|
||||
|
|
@ -87,6 +88,7 @@ const Text: TextInterface = (props) => {
|
|||
'no-wrap': !wrap,
|
||||
keyboard,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{hidden ? PLACEHOLDER : children}
|
||||
{editable && (
|
||||
|
|
@ -148,12 +150,12 @@ interface TitleProps extends TextProps {
|
|||
}
|
||||
|
||||
const Title: FC<TitleProps> = (props) => {
|
||||
const { level, className, ...restProps } = props;
|
||||
const { level, className, style, ...restProps } = props;
|
||||
// @ts-ignore
|
||||
const Tag: keyof JSX.IntrinsicElements = `h${level}`;
|
||||
|
||||
return (
|
||||
<Tag className={cx('title', className)}>
|
||||
<Tag className={cx('title', className)} style={style}>
|
||||
<Text {...restProps} />
|
||||
</Tag>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import React, { FC, useMemo } from 'react';
|
|||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
|
||||
import styles from './TimelineMarks.module.css';
|
||||
|
||||
interface TimelineMarksProps {
|
||||
|
|
@ -60,7 +62,9 @@ const TimelineMarks: FC<TimelineMarksProps> = (props) => {
|
|||
{momentsToRender.map((m, i) => {
|
||||
return (
|
||||
<div key={i} className={cx('weekday')}>
|
||||
<div className={cx('weekday-title')}>{m.moment.format('D MMM')}</div>
|
||||
<div className={cx('weekday-title')}>
|
||||
<Text type="secondary">{m.moment.format('ddd D MMM')}</Text>
|
||||
</div>
|
||||
<div className={cx('weekday-times')}>
|
||||
{m.moments.map((mm, j) => (
|
||||
<div key={j} className={cx('weekday-time')}>
|
||||
|
|
@ -69,7 +73,7 @@ const TimelineMarks: FC<TimelineMarksProps> = (props) => {
|
|||
'weekday-time-title__hidden': i === 0 && j === 0,
|
||||
})}
|
||||
>
|
||||
{mm.format('HH:mm')}
|
||||
<Text type="secondary">{mm.format('HH:mm')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
color: rgba(204, 204, 220, 0.4);
|
||||
margin: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -27,7 +26,7 @@
|
|||
display: block;
|
||||
content: "";
|
||||
flex-grow: 1;
|
||||
border-bottom: 1px solid rgba(204, 204, 220, 0.15);
|
||||
border-bottom: var(--border-medium);
|
||||
height: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
|
@ -36,7 +35,7 @@
|
|||
display: block;
|
||||
content: "";
|
||||
flex-grow: 1;
|
||||
border-bottom: 1px solid rgba(204, 204, 220, 0.15);
|
||||
border-bottom: var(--border-medium);
|
||||
height: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
|
@ -69,9 +68,13 @@
|
|||
background: var(--hover-selected-hardcoded);
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
/* display: none; */
|
||||
.icon {
|
||||
display: block;
|
||||
color: var(--always-gray);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user:hover .delete-icon {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { arrayMoveImmutable } from 'array-move';
|
|||
import cn from 'classnames/bind';
|
||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
import { User } from 'models/user/user.types';
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ interface UserGroupsProps {
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const DragHandle = () => <IconButton name="draggabledots" />;
|
||||
const DragHandle = () => <IconButton className={cx('icon')} name="draggabledots" />;
|
||||
|
||||
const SortableHandleHoc = SortableHandle(DragHandle);
|
||||
|
||||
|
|
@ -94,7 +95,7 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
{renderUser(item.data)}
|
||||
<div className={cx('user-buttons')}>
|
||||
<HorizontalGroup>
|
||||
<IconButton className={cx('delete-icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
|
||||
<IconButton className={cx('icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
|
||||
<SortableHandleHoc />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
@ -104,6 +105,16 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
return (
|
||||
<div className={cx('root')}>
|
||||
<VerticalGroup>
|
||||
<RemoteSelect
|
||||
key={items.length}
|
||||
showSearch
|
||||
placeholder="Add user"
|
||||
href="/users/?filters=true&roles=0&roles=1"
|
||||
value={null}
|
||||
onChange={handleUserAdd}
|
||||
showError={showError}
|
||||
maxMenuHeight={150}
|
||||
/>
|
||||
<SortableList
|
||||
renderItem={renderItem}
|
||||
axis="y"
|
||||
|
|
@ -116,15 +127,6 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
isMultipleGroups={isMultipleGroups}
|
||||
useDragHandle
|
||||
/>
|
||||
<RemoteSelect
|
||||
key={items.length}
|
||||
showSearch
|
||||
placeholder="Add user"
|
||||
href="/users/?filters=true&roles=0&roles=1"
|
||||
value={null}
|
||||
onChange={handleUserAdd}
|
||||
showError={showError}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -154,14 +156,16 @@ const SortableList = SortableContainer<SortableListProps>(({ items, handleAddGro
|
|||
</SortableItem>
|
||||
) : isMultipleGroups ? (
|
||||
<SortableItem key={item.key} index={index}>
|
||||
<li className={cx('separator')}>{item.data.name}</li>
|
||||
<li className={cx('separator')}>
|
||||
<Text type="secondary">{item.data.name}</Text>
|
||||
</li>
|
||||
</SortableItem>
|
||||
) : null
|
||||
)}
|
||||
{isMultipleGroups && items[items.length - 1]?.type === 'item' && (
|
||||
<SortableItem disabled key="New Group" index={items.length + 1}>
|
||||
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
|
||||
Add user group +
|
||||
<Text type="secondary">Add user group +</Text>
|
||||
</li>
|
||||
</SortableItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,46 +20,77 @@ interface UserTimezoneSelectProps {
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
|
||||
const { users, value, onChange } = props;
|
||||
const { users, value: propValue, onChange } = props;
|
||||
|
||||
const options = useMemo(() => {
|
||||
return users.reduce((memo, user) => {
|
||||
let item = memo.find((item) => item.label === user.timezone);
|
||||
return users
|
||||
.reduce(
|
||||
(memo, user) => {
|
||||
const moment = dayjs().tz(user.timezone);
|
||||
const utcOffset = moment.utcOffset();
|
||||
|
||||
if (!item) {
|
||||
item = {
|
||||
value: user.pk,
|
||||
label: `${user.timezone} ${getTzOffsetString(dayjs().tz(user.timezone))}`,
|
||||
imgUrl: user.avatar,
|
||||
description: user.username,
|
||||
};
|
||||
memo.push(item);
|
||||
} else {
|
||||
item.description += ', ' + user.name;
|
||||
// item.imgUrl = undefined;
|
||||
}
|
||||
let item = memo.find((item) => item.utcOffset === utcOffset);
|
||||
|
||||
return memo;
|
||||
}, []);
|
||||
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 selectValue = useMemo(() => {
|
||||
const user = users.find((user) => user.timezone === value);
|
||||
return user?.pk;
|
||||
}, [value, 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 user = users.find((user) => user.pk === value);
|
||||
|
||||
onChange(user?.timezone);
|
||||
const option = options.find((option) => option.utcOffset === value);
|
||||
onChange(option?.timezone);
|
||||
},
|
||||
[users]
|
||||
[options]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<Select value={selectValue} onChange={handleChange} width={100} placeholder="UTC Timezone" options={options} />
|
||||
<Select value={value} onChange={handleChange} width={100} placeholder={propValue} options={options} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface WorkingHoursProps {
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
const WorkingHours: FC<WorkingHoursProps> = (props) => {
|
||||
const { timezone, workingHours, startMoment, duration, className, style } = props;
|
||||
const { timezone, workingHours = default_working_hours, startMoment, duration, className, style } = props;
|
||||
|
||||
const endMoment = startMoment.add(duration, 'seconds');
|
||||
|
||||
|
|
|
|||
|
|
@ -804,8 +804,8 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
alertReceiveChannelStore.updateCounters();
|
||||
openNotification(
|
||||
<div>
|
||||
Demo alert was generated. Find it in the
|
||||
<PluginLink query={{ page: 'incidents' }}> "Incidents" </PluginLink>
|
||||
Demo alert was generated. Find it on the
|
||||
<PluginLink query={{ page: 'incidents' }}> "Alert Groups" </PluginLink>
|
||||
page and make sure it didn't freak out your colleagues 😉
|
||||
</div>
|
||||
);
|
||||
|
|
@ -821,8 +821,8 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
alertReceiveChannelStore.sendDemoAlertToParticularRoute(id).then(() => {
|
||||
openNotification(
|
||||
<div>
|
||||
Demo alert was generated. Find it in the
|
||||
<PluginLink query={{ page: 'incidents' }}> "Incidents" </PluginLink>
|
||||
Demo alert was generated. Find it on the
|
||||
<PluginLink query={{ page: 'incidents' }}> "Alert Groups" </PluginLink>
|
||||
page and make sure it didn't freak out your colleagues 😉
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -100,14 +100,14 @@ const ApiTokenForm = observer((props: TokenCreationModalProps) => {
|
|||
return (
|
||||
<VerticalGroup>
|
||||
<Label>Curl command example</Label>
|
||||
<SourceCode showClipboardIconOnly>{getCurlExample(token)}</SourceCode>
|
||||
<SourceCode showClipboardIconOnly>{getCurlExample(token, store.onCallApiUrl)}</SourceCode>
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function getCurlExample(token) {
|
||||
return `curl -H "Authorization: ${token}" ${getItem('onCallApiUrl')}/api/v1/integrations`;
|
||||
function getCurlExample(token, onCallApiUrl) {
|
||||
return `curl -H "Authorization: ${token}" ${onCallApiUrl}/api/v1/integrations`;
|
||||
}
|
||||
|
||||
export default ApiTokenForm;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
|
|||
// const STEP_COLORS = ['#52C41A', '#A0D911', '#FADB14', '#FAAD14', COLOR_RED];
|
||||
const STEP_COLORS = ['#1A7F4B', '#33cc33', '#ffbf00', '#FF8000', COLOR_RED];
|
||||
|
||||
const { alertReceiveChannelStore, escalationPolicyStore } = store;
|
||||
const { escalationPolicyStore } = store;
|
||||
|
||||
const escalationPolicy = escalationPolicyStore.items[escalationPolicyId];
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface GSelectProps {
|
|||
showWarningIfEmptyValue?: boolean;
|
||||
showError?: boolean;
|
||||
nullItemName?: string;
|
||||
fromOrganization?: boolean;
|
||||
filterOptions?: (id: any) => boolean;
|
||||
dropdownRender?: (menu: ReactElement) => ReactElement;
|
||||
getOptionLabel?: <T>(item: SelectableValue<T>) => React.ReactNode;
|
||||
|
|
@ -59,6 +60,7 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
showWarningIfEmptyValue = false,
|
||||
getDescription,
|
||||
filterOptions,
|
||||
fromOrganization,
|
||||
} = props;
|
||||
|
||||
const store = useStore();
|
||||
|
|
@ -123,7 +125,7 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
|
||||
(values as string[]).forEach((value: string) => {
|
||||
if (!isNil(value) && !model.items[value] && model.updateItem) {
|
||||
model.updateItem(value);
|
||||
model.updateItem(value, fromOrganization);
|
||||
}
|
||||
});
|
||||
}, [value]);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { HorizontalGroup, Icon, IconButton, Label, Tooltip } from '@grafana/ui';
|
||||
import { Icon, Label, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
|
|
@ -18,11 +16,14 @@ import styles from './GrafanaTeamSelect.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface GrafanaTeamSelectProps {}
|
||||
interface GrafanaTeamSelectProps {
|
||||
currentPage: string;
|
||||
}
|
||||
|
||||
const GrafanaTeamSelect = observer((props: GrafanaTeamSelectProps) => {
|
||||
const store = useStore();
|
||||
|
||||
const { currentPage } = props;
|
||||
const { userStore, grafanaTeamStore } = store;
|
||||
const grafanaTeams = grafanaTeamStore.getSearchResult();
|
||||
const user = userStore.currentUser;
|
||||
|
|
@ -33,7 +34,15 @@ const GrafanaTeamSelect = observer((props: GrafanaTeamSelectProps) => {
|
|||
|
||||
const onTeamChange = async (teamId: GrafanaTeam['id']) => {
|
||||
await userStore.updateCurrentUser({ current_team: teamId });
|
||||
window.location.reload();
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('page', mapCurrentPage());
|
||||
window.location.search = queryParams.toString();
|
||||
|
||||
function mapCurrentPage() {
|
||||
if (currentPage === 'incident') {return 'incidents'}
|
||||
return currentPage
|
||||
}
|
||||
};
|
||||
|
||||
return document.getElementsByClassName('page-header__inner')[0]
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
},
|
||||
{
|
||||
name: 'data',
|
||||
getDisabled: (form_data) => Boolean(form_data.forward_whole_payload),
|
||||
getDisabled: (form_data) => Boolean(form_data?.forward_whole_payload),
|
||||
type: FormItemType.TextArea,
|
||||
description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}',
|
||||
extra: {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button, Drawer, Input, Modal } from '@grafana/ui';
|
||||
import { Button, Drawer } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import Text from 'components/Text/Text';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { openErrorNotification } from 'utils';
|
||||
|
||||
import { form } from './OutgoingWebhookForm.config';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,8 @@ import {
|
|||
Label,
|
||||
Legend,
|
||||
LoadingPlaceholder,
|
||||
Icon,
|
||||
Alert,
|
||||
Modal,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { OnCallAppSettings } from 'types';
|
||||
|
||||
import Block from 'components/GBlock/Block';
|
||||
|
|
@ -28,6 +24,8 @@ import { createGrafanaToken, getPluginSyncStatus, startPluginSync, updateGrafana
|
|||
import { GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
import { getItem, setItem } from 'utils/localStorage';
|
||||
|
||||
import { constructSyncErrorMessage, constructErrorActionMessage } from './helpers';
|
||||
|
||||
import styles from './PluginConfigPage.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
|
@ -45,6 +43,8 @@ export const PluginConfigPage = (props: Props) => {
|
|||
const [isSelfHostedInstall, setIsSelfHostedInstall] = useState<boolean>(true);
|
||||
const [retrySync, setRetrySync] = useState<boolean>(false);
|
||||
|
||||
const INVALID_INVITE_TOKEN_ERROR_MSG = `It seems like your invite token may be invalid. ${constructErrorActionMessage('generating a new invite token')}`;
|
||||
|
||||
const setupPlugin = useCallback(async () => {
|
||||
setItem('onCallApiUrl', onCallApiUrl);
|
||||
setItem('grafanaUrl', grafanaUrl);
|
||||
|
|
@ -129,25 +129,37 @@ export const PluginConfigPage = (props: Props) => {
|
|||
}, []);
|
||||
|
||||
const handleSyncException = useCallback((e) => {
|
||||
const buildErrMsg = (msg: string): string =>
|
||||
constructSyncErrorMessage(msg, plugin.meta.jsonData.onCallApiUrl);
|
||||
|
||||
if (plugin.meta.jsonData?.onCallApiUrl) {
|
||||
let statusMessage = plugin.meta.jsonData.onCallApiUrl + '\n' + e + ', retry or check settings & re-initialize.';
|
||||
if (e.response.status == 404) {
|
||||
statusMessage += '\nIf Grafana OnCall was just installed, restart Grafana for OnCall routes to be available.';
|
||||
const { status: statusCode } = e.response;
|
||||
|
||||
let statusMessage: string;
|
||||
|
||||
if (statusCode == 403) {
|
||||
statusMessage = buildErrMsg(INVALID_INVITE_TOKEN_ERROR_MSG);
|
||||
} else if (statusCode === 404) {
|
||||
statusMessage = buildErrMsg('If Grafana OnCall was just installed, restart Grafana for OnCall routes to be available.');
|
||||
} else if (statusCode === 502) {
|
||||
statusMessage = buildErrMsg(`Unable to communicate with either the Grafana API, or Grafana OnCall engine API. ${constructErrorActionMessage('verify that the API URLs that you entered are correct')}`);
|
||||
} else {
|
||||
statusMessage = buildErrMsg(`An unknown error occured. ${constructErrorActionMessage()}. If the error still occurs please reach out to support.`)
|
||||
}
|
||||
setPluginStatusMessage(statusMessage);
|
||||
setRetrySync(true);
|
||||
} else {
|
||||
setPluginStatusMessage('OnCall has not been setup, configure & initialize below.');
|
||||
setPluginStatusMessage(buildErrMsg('OnCall has not been setup, configure & initialize below.'));
|
||||
}
|
||||
setPluginStatusOk(false);
|
||||
setPluginConfigLoading(false);
|
||||
}, []);
|
||||
|
||||
const finishSync = useCallback((get_sync_response) => {
|
||||
if (get_sync_response.token_ok) {
|
||||
const finishSync = useCallback((getSyncResponse) => {
|
||||
if (getSyncResponse.token_ok) {
|
||||
const versionInfo =
|
||||
get_sync_response.version && get_sync_response.license
|
||||
? ` (${get_sync_response.license}, ${get_sync_response.version})`
|
||||
getSyncResponse.version && getSyncResponse.license
|
||||
? ` (${getSyncResponse.license}, ${getSyncResponse.version})`
|
||||
: '';
|
||||
|
||||
let pluginStatusMessage = `Connected to OnCall${versionInfo}\n - OnCall URL: ${plugin.meta.jsonData.onCallApiUrl}\n`
|
||||
|
|
@ -159,9 +171,8 @@ export const PluginConfigPage = (props: Props) => {
|
|||
setIsSelfHostedInstall(plugin.meta.jsonData?.license === GRAFANA_LICENSE_OSS);
|
||||
setPluginStatusOk(true);
|
||||
} else {
|
||||
setPluginStatusMessage(
|
||||
`OnCall failed to connect to this grafana via: ${plugin.meta.jsonData.grafanaUrl} check URL, network, and API key.`
|
||||
);
|
||||
setPluginStatusMessage(constructSyncErrorMessage(INVALID_INVITE_TOKEN_ERROR_MSG,
|
||||
plugin.meta.jsonData.grafanaUrl));
|
||||
setRetrySync(true);
|
||||
}
|
||||
setPluginConfigLoading(false);
|
||||
|
|
@ -221,14 +232,10 @@ export const PluginConfigPage = (props: Props) => {
|
|||
)}
|
||||
<p>{'Plugin <-> backend connection status'}</p>
|
||||
<pre>
|
||||
<Text type="link">{pluginStatusMessage}</Text>
|
||||
<Text>{pluginStatusMessage}</Text>
|
||||
</pre>
|
||||
|
||||
<HorizontalGroup>
|
||||
{/* <p>{'Plugin <-> backend connection status'}</p>
|
||||
<pre>
|
||||
<Text type="link">{pluginStatusMessage}</Text>
|
||||
</pre> */}
|
||||
{retrySync && (
|
||||
<Button variant="primary" onClick={startSync} size="md">
|
||||
Retry
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export const constructSyncErrorMessage = (errMsg: string, url?: string): string =>
|
||||
`${url ? `${url}\n` : ''}${errMsg}`;
|
||||
|
||||
export const constructErrorActionMessage = (msg?: string): string =>
|
||||
`Try removing your current configuration, ${msg ? msg : 'double checking your settings'}, and re-initializing the plugin.\nBy removing your current configuration, you will need to ensure that you regenerate a new invite token, and input this in your new configuration.`
|
||||
|
|
@ -28,6 +28,7 @@ interface RemoteSelectProps {
|
|||
openMenuOnFocus?: boolean;
|
||||
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
|
||||
showError?: boolean;
|
||||
maxMenuHeight?: number;
|
||||
}
|
||||
|
||||
const RemoteSelect = inject('store')(
|
||||
|
|
@ -48,6 +49,7 @@ const RemoteSelect = inject('store')(
|
|||
getOptionLabel,
|
||||
openMenuOnFocus = true,
|
||||
showError,
|
||||
maxMenuHeight,
|
||||
} = props;
|
||||
|
||||
const [options, setOptions] = useState<SelectableValue[] | undefined>();
|
||||
|
|
@ -100,6 +102,7 @@ const RemoteSelect = inject('store')(
|
|||
return (
|
||||
// @ts-ignore
|
||||
<Tag
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
menuShouldPortal
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
isClearable={allowClear}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
}
|
||||
|
||||
.slots__animate {
|
||||
/* transition: transform 500ms ease; */
|
||||
transition: transform 500ms ease;
|
||||
}
|
||||
|
||||
.slots__transparent {
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
position: absolute;
|
||||
left: 450px;
|
||||
width: 1px;
|
||||
background: #fff;
|
||||
background: var(--gradient-brandVertical);
|
||||
top: -10px;
|
||||
bottom: -10px;
|
||||
z-index: 1;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { DateTime, dateTime } from '@grafana/data';
|
||||
import { DatePickerWithInput, HorizontalGroup, TimeOfDayPicker, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Moment } from 'moment-timezone';
|
||||
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { getUserNotificationsSummary } from 'models/user/user.helpers';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
import styles from 'containers/UserTooltip/UserTooltip.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface UserTooltipProps {
|
||||
value: dayjs.Dayjs;
|
||||
timezone: Timezone;
|
||||
onChange: (value: dayjs.Dayjs) => void;
|
||||
disabled?: boolean;
|
||||
minMoment?: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => {
|
||||
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? moment : moment.tz(timezone);
|
||||
|
||||
return new Date(
|
||||
localMoment.get('year'),
|
||||
localMoment.get('month'),
|
||||
localMoment.get('date'),
|
||||
localMoment.get('hour'),
|
||||
localMoment.get('minute'),
|
||||
localMoment.get('second')
|
||||
);
|
||||
};
|
||||
|
||||
const DateTimePicker = (props: UserTooltipProps) => {
|
||||
const { value: propValue, minMoment, timezone, onChange, disabled } = props;
|
||||
|
||||
const value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]);
|
||||
|
||||
const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(newDate: Date) => {
|
||||
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone);
|
||||
|
||||
const newValue = localMoment
|
||||
.set('year', newDate.getFullYear())
|
||||
.set('month', newDate.getMonth())
|
||||
.set('date', newDate.getDate())
|
||||
.set('hour', value.getHours())
|
||||
.set('minute', value.getMinutes())
|
||||
.set('second', value.getSeconds());
|
||||
|
||||
onChange(newValue);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(newMoment: DateTime) => {
|
||||
const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone);
|
||||
const newDate = newMoment.toDate();
|
||||
const newValue = localMoment
|
||||
.set('year', value.getFullYear())
|
||||
.set('month', value.getMonth())
|
||||
.set('date', value.getDate())
|
||||
.set('hour', newDate.getHours())
|
||||
.set('minute', newDate.getMinutes())
|
||||
.set('second', newDate.getSeconds());
|
||||
|
||||
onChange(newValue);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalGroup spacing="sm">
|
||||
<DatePickerWithInput minDate={minDate} disabled={disabled} value={value} onChange={handleDateChange} />
|
||||
<TimeOfDayPicker disabled={disabled} value={dateTime(value)} onChange={handleTimeChange} />
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimePicker;
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
.draggable {
|
||||
top: 0;
|
||||
transition: transform 300ms ease;
|
||||
|
||||
/* transition: transform 300ms ease; */
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
@ -31,12 +32,8 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.date-time-picker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline-switch {
|
||||
height: 22px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.days {
|
||||
|
|
@ -58,3 +55,17 @@
|
|||
.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
Field,
|
||||
Input,
|
||||
Button,
|
||||
DateTimePicker,
|
||||
Select,
|
||||
InlineSwitch,
|
||||
DatePickerWithInput,
|
||||
TimeOfDayPicker,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -36,6 +37,7 @@ 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';
|
||||
|
|
@ -56,6 +58,10 @@ interface RotationFormProps {
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const repeatShiftsEveryOptions = Array.from(Array(31).keys())
|
||||
.slice(1)
|
||||
.map((i) => ({ label: String(i), value: i }));
|
||||
|
||||
const RotationForm: FC<RotationFormProps> = observer((props) => {
|
||||
const {
|
||||
onHide,
|
||||
|
|
@ -78,13 +84,17 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
|
||||
const [repeatEveryPeriod, setRepeatEveryPeriod] = useState<number>(0);
|
||||
const [selectedDays, setSelectedDays] = useState<string[]>([]);
|
||||
const [shiftStart, setShiftStart] = useState<DateTime>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
|
||||
const [shiftEnd, setShiftEnd] = useState<DateTime>(dateTime(shiftMoment.add(1, 'day').format('YYYY-MM-DD HH:mm:ss')));
|
||||
const [rotationStart, setRotationStart] = useState<DateTime>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
|
||||
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(shiftMoment);
|
||||
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(shiftMoment.add(1, 'day'));
|
||||
const [rotationStart, setRotationStart] = useState<dayjs.Dayjs>(shiftMoment);
|
||||
const [endLess, setEndless] = useState<boolean>(true);
|
||||
const [rotationEnd, setRotationEnd] = useState<DateTime>(
|
||||
dateTime(shiftMoment.add(1, 'month').format('YYYY-MM-DD HH:mm:ss'))
|
||||
);
|
||||
const [rotationEnd, setRotationEnd] = useState<dayjs.Dayjs>(shiftMoment.add(1, 'month'));
|
||||
|
||||
useEffect(() => {
|
||||
if (rotationStart.isBefore(shiftStart)) {
|
||||
setRotationStart(shiftStart);
|
||||
}
|
||||
}, [rotationStart, shiftStart]);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
|
|
@ -102,7 +112,12 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
// elm.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
|
||||
// setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0));
|
||||
|
||||
setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10));
|
||||
const offsetTop = Math.min(
|
||||
Math.max(coords.top - modal?.offsetHeight - 10, 10),
|
||||
document.body.offsetHeight - modal?.offsetHeight - 10
|
||||
);
|
||||
|
||||
setOffsetTop(offsetTop);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
|
@ -118,7 +133,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
return (
|
||||
<>
|
||||
<div className={cx('user-title')}>
|
||||
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
|
||||
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
|
||||
</div>
|
||||
<WorkingHours
|
||||
timezone={timezone}
|
||||
|
|
@ -146,10 +161,10 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
rotation_start: getUTCString(rotationStart, currentTimezone),
|
||||
until: endLess ? null : getUTCString(rotationEnd, currentTimezone),
|
||||
shift_start: getUTCString(shiftStart, currentTimezone),
|
||||
shift_end: getUTCString(shiftEnd, currentTimezone),
|
||||
rotation_start: getUTCString(rotationStart),
|
||||
until: endLess ? null : getUTCString(rotationEnd),
|
||||
shift_start: getUTCString(shiftStart),
|
||||
shift_end: getUTCString(shiftEnd),
|
||||
rolling_users: userGroups,
|
||||
interval: repeatEveryValue,
|
||||
frequency: repeatEveryPeriod,
|
||||
|
|
@ -205,15 +220,15 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (shift) {
|
||||
setRotationStart(getDateTime(shift.rotation_start, currentTimezone));
|
||||
setRotationEnd(getDateTime(shift.until, currentTimezone));
|
||||
setShiftStart(getDateTime(shift.shift_start, currentTimezone));
|
||||
setShiftEnd(getDateTime(shift.shift_end, currentTimezone));
|
||||
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);
|
||||
setSelectedDays(shift.by_day || []);
|
||||
|
||||
setUserGroups(shift.rolling_users);
|
||||
}
|
||||
|
|
@ -230,6 +245,8 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
setRepeatEveryValue(option.value);
|
||||
}, []);
|
||||
|
||||
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
|
||||
|
||||
const moment = dayjs();
|
||||
|
||||
return (
|
||||
|
|
@ -252,8 +269,8 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
</HorizontalGroup>
|
||||
</Text>
|
||||
<HorizontalGroup>
|
||||
<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
|
||||
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />
|
||||
{/*<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
|
||||
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />*/}
|
||||
{shiftId !== 'new' && (
|
||||
<WithConfirm>
|
||||
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
|
||||
|
|
@ -262,116 +279,115 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
|
|||
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<UserGroups
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={true}
|
||||
renderUser={renderUser}
|
||||
showError={!userGroups.some((group) => group.length)}
|
||||
/>
|
||||
{/*<hr />*/}
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup>
|
||||
<Field className={cx('control')} label="Repeat shifts every">
|
||||
<Select
|
||||
value={repeatEveryValue}
|
||||
options={[
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3', value: 3 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '5', value: 5 },
|
||||
{ label: '6', value: 6 },
|
||||
{ label: '7', value: 7 },
|
||||
]}
|
||||
onChange={handleRepeatEveryValueChange}
|
||||
/>
|
||||
</Field>
|
||||
<Field className={cx('control')} label="">
|
||||
<RemoteSelect
|
||||
href="/oncall_shifts/frequency_options/"
|
||||
value={repeatEveryPeriod}
|
||||
onChange={setRepeatEveryPeriod}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
{repeatEveryPeriod === 1 && (
|
||||
/*<HorizontalGroup justify="center">*/
|
||||
<Field label="Select days to repeat">
|
||||
<DaysSelector
|
||||
options={store.scheduleStore.byDayOptions}
|
||||
value={selectedDays}
|
||||
onChange={(value) => setSelectedDays(value)}
|
||||
/>
|
||||
</Field>
|
||||
/*</HorizontalGroup>*/
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Shift start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker date={shiftStart} onChange={setShiftStart} />
|
||||
</Field>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Shift end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker date={shiftEnd} onChange={setShiftEnd} />
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Rotation start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker date={rotationStart} onChange={setRotationStart} />
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<HorizontalGroup spacing="xs">
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<div className={cx('two-fields')}>
|
||||
<Field
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Rotation end
|
||||
Rotation start
|
||||
</Text>
|
||||
<InlineSwitch
|
||||
className={cx('inline-switch')}
|
||||
transparent
|
||||
value={!endLess}
|
||||
onChange={handleChangeEndless}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
}
|
||||
>
|
||||
{endLess ? (
|
||||
<Input
|
||||
value="endless"
|
||||
onClick={() => {
|
||||
setEndless(false);
|
||||
}}
|
||||
}
|
||||
>
|
||||
<DateTimePicker
|
||||
minMoment={shiftStart}
|
||||
value={rotationStart}
|
||||
onChange={setRotationStart}
|
||||
timezone={currentTimezone}
|
||||
/>
|
||||
) : (
|
||||
<DateTimePicker date={rotationEnd} onChange={setRotationEnd} />
|
||||
)}
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Text type="primary" size="small">
|
||||
Rotation end
|
||||
</Text>
|
||||
<InlineSwitch
|
||||
className={cx('inline-switch')}
|
||||
transparent
|
||||
value={!endLess}
|
||||
onChange={handleChangeEndless}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
}
|
||||
>
|
||||
{endLess ? (
|
||||
<div style={{ lineHeight: '32px' }}>
|
||||
<Text type="secondary">Endless</Text>
|
||||
</div>
|
||||
) : (
|
||||
<DateTimePicker value={rotationEnd} onChange={setRotationEnd} timezone={currentTimezone} />
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<HorizontalGroup>
|
||||
<Field className={cx('control')} label="Repeat shifts every">
|
||||
<Select
|
||||
maxMenuHeight={120}
|
||||
value={repeatEveryValue}
|
||||
options={repeatShiftsEveryOptions}
|
||||
onChange={handleRepeatEveryValueChange}
|
||||
allowCustomValue
|
||||
/>
|
||||
</Field>
|
||||
<Field className={cx('control')} label="">
|
||||
<RemoteSelect
|
||||
href="/oncall_shifts/frequency_options/"
|
||||
value={repeatEveryPeriod}
|
||||
onChange={setRepeatEveryPeriod}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
{repeatEveryPeriod === 1 && (
|
||||
/*<HorizontalGroup justify="center">*/
|
||||
<Field label="Select days to repeat">
|
||||
<DaysSelector
|
||||
options={store.scheduleStore.byDayOptions}
|
||||
value={selectedDays}
|
||||
onChange={(value) => setSelectedDays(value)}
|
||||
/>
|
||||
</Field>
|
||||
/*</HorizontalGroup>*/
|
||||
)}
|
||||
<div className={cx('two-fields')}>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Shift start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker value={shiftStart} onChange={setShiftStart} timezone={currentTimezone} />
|
||||
</Field>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Shift end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker value={shiftEnd} onChange={setShiftEnd} timezone={currentTimezone} />
|
||||
</Field>
|
||||
</div>
|
||||
<UserGroups
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={true}
|
||||
renderUser={renderUser}
|
||||
showError={!isFormValid}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
|
||||
<HorizontalGroup>
|
||||
{/*<Button variant="secondary">+ Override</Button>*/}
|
||||
<Button variant="primary" onClick={handleCreate}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
{shiftId === 'new' ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
|
||||
{shiftId === 'new' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { dateTime, DateTime } from '@grafana/data';
|
||||
import {
|
||||
IconButton,
|
||||
VerticalGroup,
|
||||
HorizontalGroup,
|
||||
Field,
|
||||
Input,
|
||||
Button,
|
||||
DateTimePicker,
|
||||
Select,
|
||||
InlineSwitch,
|
||||
} from '@grafana/ui';
|
||||
import { IconButton, VerticalGroup, HorizontalGroup, Field, Input, Button, Select, InlineSwitch } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import Draggable from 'react-draggable';
|
||||
|
|
@ -31,6 +21,7 @@ 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';
|
||||
|
|
@ -77,15 +68,18 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
|
||||
const coords = getCoords(elm);
|
||||
|
||||
setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10));
|
||||
const offsetTop = Math.min(
|
||||
Math.max(coords.top - modal?.offsetHeight - 10, 10),
|
||||
document.body.offsetHeight - modal?.offsetHeight - 10
|
||||
);
|
||||
|
||||
setOffsetTop(offsetTop);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const [shiftStart, setShiftStart] = useState<DateTime>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
|
||||
const [shiftEnd, setShiftEnd] = useState<DateTime>(
|
||||
dateTime(shiftMoment.add(24, 'hours').format('YYYY-MM-DD HH:mm:ss'))
|
||||
);
|
||||
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(shiftMoment);
|
||||
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(shiftMoment.add(24, 'hours'));
|
||||
|
||||
const [userGroups, setUserGroups] = useState([[]]);
|
||||
|
||||
|
|
@ -98,7 +92,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
return (
|
||||
<>
|
||||
<div className={cx('user-title')}>
|
||||
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
|
||||
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
|
||||
</div>
|
||||
<WorkingHours
|
||||
timezone={timezone}
|
||||
|
|
@ -122,9 +116,9 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
rotation_start: getUTCString(shiftStart, currentTimezone),
|
||||
shift_start: getUTCString(shiftStart, currentTimezone),
|
||||
shift_end: getUTCString(shiftEnd, currentTimezone),
|
||||
rotation_start: getUTCString(shiftStart),
|
||||
shift_start: getUTCString(shiftStart),
|
||||
shift_end: getUTCString(shiftEnd),
|
||||
rolling_users: userGroups,
|
||||
frequency: null,
|
||||
}),
|
||||
|
|
@ -133,8 +127,8 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (shift) {
|
||||
setShiftStart(getDateTime(shift.shift_start, currentTimezone));
|
||||
setShiftEnd(getDateTime(shift.shift_end, currentTimezone));
|
||||
setShiftStart(getDateTime(shift.shift_start));
|
||||
setShiftEnd(getDateTime(shift.shift_end));
|
||||
|
||||
setUserGroups(shift.rolling_users);
|
||||
}
|
||||
|
|
@ -176,6 +170,8 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
|
||||
const handleChange = useDebouncedCallback(updatePreview, 200);
|
||||
|
||||
const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]);
|
||||
|
||||
useEffect(handleChange, [params]);
|
||||
|
||||
return (
|
||||
|
|
@ -193,8 +189,8 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
<HorizontalGroup justify="space-between">
|
||||
<Text size="medium">{shiftId === 'new' ? 'New Override' : 'Update Override'}</Text>
|
||||
<HorizontalGroup>
|
||||
<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
|
||||
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />
|
||||
{/*<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
|
||||
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />*/}
|
||||
{shiftId !== 'new' && (
|
||||
<WithConfirm>
|
||||
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
|
||||
|
|
@ -203,42 +199,46 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<UserGroups
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={false}
|
||||
renderUser={renderUser}
|
||||
showError={!userGroups.some((group) => group.length)}
|
||||
/>
|
||||
{/*<hr />*/}
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker date={shiftStart} onChange={setShiftStart} />
|
||||
</Field>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker date={shiftEnd} onChange={setShiftEnd} />
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override start
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker value={shiftStart} onChange={setShiftStart} timezone={currentTimezone} />
|
||||
</Field>
|
||||
<Field
|
||||
className={cx('date-time-picker')}
|
||||
label={
|
||||
<Text type="primary" size="small">
|
||||
Override end
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<DateTimePicker value={shiftEnd} onChange={setShiftEnd} timezone={currentTimezone} />
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
<UserGroups
|
||||
value={userGroups}
|
||||
onChange={setUserGroups}
|
||||
isMultipleGroups={false}
|
||||
renderUser={renderUser}
|
||||
showError={!isFormValid}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={handleCreate}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
{shiftId === 'new' ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
|
||||
{shiftId === 'new' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
.root {
|
||||
border: var(--rotations-border);
|
||||
border-radius: 2px;
|
||||
background: var(--rotations-background);
|
||||
border: var(--rotations-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.current-time {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background: #fff;
|
||||
background: var(--gradient-brandVertical);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
|
|
@ -19,10 +21,6 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
|
|
@ -42,11 +40,7 @@
|
|||
.layer-title {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
|
@ -82,7 +76,6 @@
|
|||
line-height: 16px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { toJS } from 'mobx';
|
|||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
|
||||
import Rotation from 'containers/Rotation/Rotation';
|
||||
import RotationForm from 'containers/RotationForm/RotationForm';
|
||||
|
|
@ -30,6 +31,8 @@ interface RotationsProps extends WithStoreProps {
|
|||
startMoment: dayjs.Dayjs;
|
||||
currentTimezone: Timezone;
|
||||
scheduleId: Schedule['id'];
|
||||
shiftIdToShowRotationForm?: Shift['id'] | 'new';
|
||||
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
|
||||
onClick: (id: Shift['id'] | 'new') => void;
|
||||
onCreate: () => void;
|
||||
onUpdate: () => void;
|
||||
|
|
@ -37,7 +40,6 @@ interface RotationsProps extends WithStoreProps {
|
|||
}
|
||||
|
||||
interface RotationsState {
|
||||
shiftIdToShowRotationForm?: Shift['id'];
|
||||
layerPriority?: Layer['priority'];
|
||||
shiftMomentToShowRotationForm?: dayjs.Dayjs;
|
||||
}
|
||||
|
|
@ -45,13 +47,23 @@ interface RotationsState {
|
|||
@observer
|
||||
class Rotations extends Component<RotationsProps, RotationsState> {
|
||||
state: RotationsState = {
|
||||
shiftIdToShowRotationForm: undefined,
|
||||
layerPriority: undefined,
|
||||
shiftMomentToShowRotationForm: undefined,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, onClick } = this.props;
|
||||
const { shiftIdToShowRotationForm, layerPriority, shiftMomentToShowRotationForm } = this.state;
|
||||
const {
|
||||
scheduleId,
|
||||
startMoment,
|
||||
currentTimezone,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
store,
|
||||
onClick,
|
||||
shiftIdToShowRotationForm,
|
||||
} = this.props;
|
||||
const { layerPriority, shiftMomentToShowRotationForm } = this.state;
|
||||
|
||||
const base = 7 * 24 * 60; // in minutes
|
||||
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
|
||||
|
|
@ -80,12 +92,16 @@ class Rotations extends Component<RotationsProps, RotationsState> {
|
|||
<div className={cx('root')}>
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>Rotations</div>
|
||||
<div className={cx('title')}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Rotations
|
||||
</Text.Title>
|
||||
</div>
|
||||
<ValuePicker
|
||||
label="Add rotation"
|
||||
options={options}
|
||||
onChange={this.handleAddRotation}
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
size="md"
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
|
|
@ -98,7 +114,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
|
|||
<div id={`layer${layer.priority}`} className={cx('layer')}>
|
||||
<div className={cx('layer-title')}>
|
||||
<HorizontalGroup spacing="sm" justify="center">
|
||||
<span>Layer {layer.priority}</span>
|
||||
<Text type="secondary">Layer {layer.priority}</Text>
|
||||
{/*<Icon name="info-circle" />*/}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
@ -140,8 +156,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
|
|||
<div id={`layer1`} className={cx('layer')}>
|
||||
<div className={cx('layer-title')}>
|
||||
<HorizontalGroup spacing="sm" justify="center">
|
||||
<span>Layer 1</span>
|
||||
{/* <Icon name="info-circle" />*/}
|
||||
<Text type="secondary">Layer 1</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('header-plus-content')}>
|
||||
|
|
@ -168,10 +183,10 @@ class Rotations extends Component<RotationsProps, RotationsState> {
|
|||
<div
|
||||
className={cx('add-rotations-layer')}
|
||||
onClick={() => {
|
||||
this.handleAddLayer(nextPriority, startMoment);
|
||||
this.handleAddLayer(nextPriority);
|
||||
}}
|
||||
>
|
||||
+ Add rotations layer
|
||||
<Text type="primary">+ Add rotations layer</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -212,31 +227,49 @@ class Rotations extends Component<RotationsProps, RotationsState> {
|
|||
}
|
||||
|
||||
onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => {
|
||||
this.setState({ shiftIdToShowRotationForm: shiftId, shiftMomentToShowRotationForm: moment });
|
||||
this.setState({ shiftMomentToShowRotationForm: moment }, () => {
|
||||
this.onShowRotationForm(shiftId);
|
||||
});
|
||||
};
|
||||
|
||||
handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => {
|
||||
this.setState({ shiftIdToShowRotationForm: 'new', layerPriority, shiftMomentToShowRotationForm: moment });
|
||||
this.setState({ layerPriority, shiftMomentToShowRotationForm: moment }, () => {
|
||||
this.onShowRotationForm('new');
|
||||
});
|
||||
};
|
||||
|
||||
handleAddRotation = (option: SelectableValue) => {
|
||||
const { startMoment } = this.props;
|
||||
|
||||
this.setState({
|
||||
shiftIdToShowRotationForm: 'new',
|
||||
layerPriority: option.value,
|
||||
shiftMomentToShowRotationForm: startMoment,
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
layerPriority: option.value,
|
||||
shiftMomentToShowRotationForm: startMoment,
|
||||
},
|
||||
() => {
|
||||
this.onShowRotationForm('new');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
hideRotationForm = () => {
|
||||
const { store } = this.props;
|
||||
|
||||
this.setState({
|
||||
shiftIdToShowRotationForm: undefined,
|
||||
layerPriority: undefined,
|
||||
shiftMomentToShowRotationForm: undefined,
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
layerPriority: undefined,
|
||||
shiftMomentToShowRotationForm: undefined,
|
||||
},
|
||||
() => {
|
||||
this.onShowRotationForm(undefined);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
onShowRotationForm = (shiftId: Shift['id']) => {
|
||||
const { onShowRotationForm } = this.props;
|
||||
|
||||
onShowRotationForm(shiftId);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import React, { Component, useEffect } from 'react';
|
|||
import { Button, HorizontalGroup, Icon, Input, ValuePicker } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { toJS } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
|
||||
import Rotation from 'containers/Rotation/Rotation';
|
||||
import { getColor, getFromString, getOverrideColor } from 'models/schedule/schedule.helpers';
|
||||
import { Event, Layer, Schedule } from 'models/schedule/schedule.types';
|
||||
import { Event, Layer, Schedule, Shift } from 'models/schedule/schedule.types';
|
||||
import { Timezone } from 'models/timezone/timezone.types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -27,6 +27,7 @@ interface ScheduleFinalProps extends WithStoreProps {
|
|||
currentTimezone: Timezone;
|
||||
scheduleId: Schedule['id'];
|
||||
hideHeader?: boolean;
|
||||
onClick: (shiftId: Shift['id']) => void;
|
||||
}
|
||||
|
||||
interface ScheduleOverridesState {
|
||||
|
|
@ -73,7 +74,11 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
{!hideHeader && (
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>Final schedule</div>
|
||||
<div className={cx('title')}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Final schedule
|
||||
</Text.Title>
|
||||
</div>
|
||||
{/*<Input
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder="Search..."
|
||||
|
|
@ -98,6 +103,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
startMoment={startMoment}
|
||||
currentTimezone={currentTimezone}
|
||||
color={findColor(shiftId, layers, overrides)}
|
||||
onClick={this.getRotationClickHandler(shiftId)}
|
||||
/>
|
||||
</CSSTransition>
|
||||
);
|
||||
|
|
@ -119,6 +125,14 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
|
|||
);
|
||||
}
|
||||
|
||||
getRotationClickHandler = (shiftId: Shift['id']) => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
return () => {
|
||||
onClick(shiftId);
|
||||
};
|
||||
};
|
||||
|
||||
onSearchTermChangeCallback = () => {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import dayjs from 'dayjs';
|
|||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
|
||||
import Rotation from 'containers/Rotation/Rotation';
|
||||
import { RotationCreateData } from 'containers/RotationForm/RotationForm.types';
|
||||
|
|
@ -27,26 +28,27 @@ 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 {
|
||||
shiftIdToShowOverrideForm?: Shift['id'] | 'new';
|
||||
shiftMomentToShowOverrideForm?: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
@observer
|
||||
class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverridesState> {
|
||||
state: ScheduleOverridesState = {
|
||||
shiftIdToShowOverrideForm: undefined,
|
||||
shiftMomentToShowOverrideForm: undefined,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store } = this.props;
|
||||
const { shiftIdToShowOverrideForm, shiftMomentToShowOverrideForm } = this.state;
|
||||
const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, shiftIdToShowRotationForm } =
|
||||
this.props;
|
||||
const { shiftMomentToShowOverrideForm } = this.state;
|
||||
|
||||
const shifts = store.scheduleStore.overridePreview
|
||||
? store.scheduleStore.overridePreview
|
||||
|
|
@ -68,7 +70,11 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
|
|||
<div id="overrides-list" className={cx('root')}>
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>Overrides</div>
|
||||
<div className={cx('title')}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Overrides
|
||||
</Text.Title>
|
||||
</div>
|
||||
<Button icon="plus" onClick={this.handleAddOverride} variant="secondary">
|
||||
Add override
|
||||
</Button>
|
||||
|
|
@ -114,10 +120,10 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
|
|||
+ Add override
|
||||
</div>*/}
|
||||
</div>
|
||||
{shiftIdToShowOverrideForm && (
|
||||
{shiftIdToShowRotationForm && (
|
||||
<ScheduleOverrideForm
|
||||
shiftId={shiftIdToShowOverrideForm}
|
||||
shiftColor={findColor(shiftIdToShowOverrideForm, undefined, shifts)}
|
||||
shiftId={shiftIdToShowRotationForm}
|
||||
shiftColor={findColor(shiftIdToShowRotationForm, undefined, shifts)}
|
||||
scheduleId={scheduleId}
|
||||
startMoment={startMoment}
|
||||
currentTimezone={currentTimezone}
|
||||
|
|
@ -149,17 +155,29 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
|
|||
}
|
||||
|
||||
onRotationClick = (shiftId: Shift['id'], moment: dayjs.Dayjs) => {
|
||||
this.setState({ shiftIdToShowOverrideForm: shiftId, shiftMomentToShowOverrideForm: moment });
|
||||
this.setState({ shiftMomentToShowOverrideForm: moment }, () => {
|
||||
this.onShowRotationForm(shiftId);
|
||||
});
|
||||
};
|
||||
|
||||
handleAddOverride = () => {
|
||||
const { startMoment } = this.props;
|
||||
|
||||
this.setState({ shiftIdToShowOverrideForm: 'new', shiftMomentToShowOverrideForm: startMoment });
|
||||
this.setState({ shiftMomentToShowOverrideForm: startMoment }, () => {
|
||||
this.onShowRotationForm('new');
|
||||
});
|
||||
};
|
||||
|
||||
handleHide = () => {
|
||||
this.setState({ shiftIdToShowOverrideForm: undefined, shiftMomentToShowOverrideForm: undefined });
|
||||
this.setState({ shiftMomentToShowOverrideForm: undefined }, () => {
|
||||
this.onShowRotationForm(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
onShowRotationForm = (shiftId: Shift['id']) => {
|
||||
const { onShowRotationForm } = this.props;
|
||||
|
||||
onShowRotationForm(shiftId);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,8 +166,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
|
|||
<HorizontalGroup spacing="sm">
|
||||
<img src={Line} />
|
||||
<VerticalGroup spacing="none">
|
||||
<Text type="secondary">{dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
|
||||
<Text type="secondary">{dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
|
||||
<Text type="secondary">{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
|
||||
<Text type="secondary">{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
border: var(--border-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
background: var(--background-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
position: absolute;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: #fff;
|
||||
background: var(--gradient-brandVertical);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
|
|
@ -70,6 +70,7 @@
|
|||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
background: #454952;
|
||||
color: #ccccdc;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
transition: opacity 200ms ease, left 200ms ease;
|
||||
|
|
@ -114,7 +115,6 @@
|
|||
top: -24px;
|
||||
display: flex;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -32,9 +32,6 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
|
|||
|
||||
const store = useStore();
|
||||
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const [currentMoment, setCurrentMoment] = useState<dayjs.Dayjs>(dayjs().tz(tz));
|
||||
|
||||
useEffect(() => {
|
||||
userIds.forEach((userId) => {
|
||||
if (!store.userStore.items[userId]) {
|
||||
|
|
@ -48,18 +45,7 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
|
|||
[userIds, store.userStore.items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentMoment(currentMoment.tz(tz).startOf('minute'));
|
||||
}, [tz]);
|
||||
|
||||
/*useInterval(
|
||||
() => {
|
||||
setCurrentMoment(currentMoment.add(10, 'minute'));
|
||||
//setCount(count + 1);
|
||||
},
|
||||
// Delay in milliseconds or null to stop it
|
||||
1000,
|
||||
);*/
|
||||
const currentMoment = useMemo(() => dayjs().tz(tz), [tz]);
|
||||
|
||||
const currentTimeX = useMemo(() => {
|
||||
const midnight = dayjs().tz(tz).startOf('day');
|
||||
|
|
@ -85,7 +71,11 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
|
|||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('title')}>Schedule team and timezones</div>
|
||||
<div className={cx('title')}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Schedule team and timezones
|
||||
</Text.Title>
|
||||
</div>
|
||||
{/* <HorizontalGroup>
|
||||
<InlineSwitch transparent />
|
||||
Current schedule users only
|
||||
|
|
@ -112,12 +102,18 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
|
|||
'time-mark-text__translated': index > 0,
|
||||
})}
|
||||
>
|
||||
{mm.format('HH:mm')}
|
||||
<Text type="secondary" size="small">
|
||||
{mm.format('HH:mm')}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div key={jLimit} className={cx('time-mark')}>
|
||||
<span className={cx('time-mark-text')}>24:00</span>
|
||||
<span className={cx('time-mark-text')}>
|
||||
<Text type="secondary" size="small">
|
||||
24:00
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ export const ExpandIcon = (props: IconProps) => {
|
|||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11 1.16994C10.8126 0.983692 10.5592 0.87915 10.295 0.87915C10.0308 0.87915 9.77737 0.983692 9.59001 1.16994L6.00001 4.70994L2.46001 1.16994C2.27265 0.983692 2.0192 0.87915 1.75501 0.87915C1.49082 0.87915 1.23737 0.983692 1.05001 1.16994C0.956281 1.26291 0.881887 1.37351 0.831118 1.49537C0.780349 1.61723 0.754211 1.74793 0.754211 1.87994C0.754211 2.01195 0.780349 2.14266 0.831118 2.26452C0.881887 2.38638 0.956281 2.49698 1.05001 2.58994L5.29001 6.82994C5.38297 6.92367 5.49357 6.99806 5.61543 7.04883C5.73729 7.0996 5.868 7.12574 6.00001 7.12574C6.13202 7.12574 6.26273 7.0996 6.38459 7.04883C6.50645 6.99806 6.61705 6.92367 6.71001 6.82994L11 2.58994C11.0937 2.49698 11.1681 2.38638 11.2189 2.26452C11.2697 2.14266 11.2958 2.01195 11.2958 1.87994C11.2958 1.74793 11.2697 1.61723 11.2189 1.49537C11.1681 1.37351 11.0937 1.26291 11 1.16994Z"
|
||||
fill="#CCCCDC"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.65"
|
||||
/>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,18 @@ export class AlertReceiveChannelStore extends BaseStore {
|
|||
);
|
||||
}
|
||||
|
||||
@action
|
||||
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
|
||||
const alertReceiveChannel = await this.getById(id, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: alertReceiveChannel
|
||||
}
|
||||
|
||||
return alertReceiveChannel
|
||||
}
|
||||
|
||||
@action
|
||||
async updateItems(query = '') {
|
||||
const result = await this.getAll(query);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export interface Alert {
|
|||
acknowledged_by_user: User;
|
||||
acknowledged_on_source: boolean;
|
||||
channel: Channel;
|
||||
permalink?: string;
|
||||
slack_permalink?: string;
|
||||
related_users: User[];
|
||||
render_after_resolve_report_json?: TimeLineItem[];
|
||||
render_for_slack: { attachments: any[] };
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export default class BaseStore {
|
|||
this.rootStore = rootStore;
|
||||
}
|
||||
|
||||
onApiError(error: any) {
|
||||
onApiError(error: any, skipErrorHandling = false) {
|
||||
if (skipErrorHandling) {
|
||||
throw error; // rethrow error and skip additional handling like showing notification
|
||||
}
|
||||
|
||||
if (error.response.status >= 400 && error.response.status < 500) {
|
||||
const payload = error.response.data;
|
||||
const text =
|
||||
|
|
@ -37,10 +41,11 @@ export default class BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
async getById(id: string) {
|
||||
return await makeRequest(`${this.path}${id}/`, {
|
||||
async getById(id: string, skipErrorHandling = false, fromOrganization = false) {
|
||||
return await makeRequest(`${this.path}${id}`, {
|
||||
method: 'GET',
|
||||
}).catch(this.onApiError);
|
||||
params: { from_organization: fromOrganization },
|
||||
}).catch((error) => this.onApiError(error, skipErrorHandling));
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ export class EscalationChainStore extends BaseStore {
|
|||
this.path = '/escalation_chains/';
|
||||
}
|
||||
|
||||
@action
|
||||
async loadItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
|
||||
const escalationChain = await this.getById(id, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: escalationChain
|
||||
}
|
||||
|
||||
return escalationChain
|
||||
}
|
||||
|
||||
@action
|
||||
async updateById(id: EscalationChain['id']) {
|
||||
const response = await this.getById(id);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ export class OutgoingWebhookStore extends BaseStore {
|
|||
this.path = '/custom_buttons/';
|
||||
}
|
||||
|
||||
@action
|
||||
async loadItem(id: OutgoingWebhook['id'], skipErrorHandling = false): Promise<OutgoingWebhook> {
|
||||
const outgoingWebhook = await this.getById(id, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: outgoingWebhook,
|
||||
};
|
||||
|
||||
return outgoingWebhook;
|
||||
}
|
||||
|
||||
@action
|
||||
async updateById(id: OutgoingWebhook['id']) {
|
||||
const response = await this.getById(id);
|
||||
|
|
@ -30,8 +42,8 @@ export class OutgoingWebhookStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
async updateItem(id: OutgoingWebhook['id']) {
|
||||
const response = await this.getById(id);
|
||||
async updateItem(id: OutgoingWebhook['id'], fromOrganization = false) {
|
||||
const response = await this.getById(id, false, fromOrganization);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,18 @@ export class ScheduleStore extends BaseStore {
|
|||
this.path = '/schedules/';
|
||||
}
|
||||
|
||||
@action
|
||||
async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise<Schedule> {
|
||||
const schedule = await this.getById(id, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: schedule,
|
||||
};
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@action
|
||||
async updateScheduleEvents(
|
||||
scheduleId: Schedule['id'],
|
||||
|
|
@ -121,9 +133,9 @@ export class ScheduleStore extends BaseStore {
|
|||
};
|
||||
}
|
||||
|
||||
async updateItem(id: Schedule['id']) {
|
||||
async updateItem(id: Schedule['id'], fromOrganization = false) {
|
||||
if (id) {
|
||||
const item = await this.getById(id);
|
||||
const item = await this.getById(id, true, fromOrganization);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
|
|
@ -293,6 +305,8 @@ export class ScheduleStore extends BaseStore {
|
|||
...this.shifts,
|
||||
[shiftId]: response,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteOncallShift(shiftId: Shift['id']) {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export interface Shift {
|
|||
shift_end: string;
|
||||
shift_start: string;
|
||||
title: string;
|
||||
type: number;
|
||||
type: number; // 2 - rotations, 3 - overrides
|
||||
until: string | null;
|
||||
updated_shift: null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,17 +60,21 @@ export class UserStore extends BaseStore {
|
|||
this.update(response.pk, { timezone });
|
||||
}
|
||||
|
||||
timezone = timezone || getTimezone(response);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[response.pk]: { ...response, timezone: timezone || getTimezone(response) },
|
||||
[response.pk]: { ...response, timezone },
|
||||
};
|
||||
|
||||
this.currentUserPk = response.pk;
|
||||
|
||||
// this.rootStore.currentTimezone = timezone;
|
||||
}
|
||||
|
||||
@action
|
||||
async loadUser(userPk: User['pk']) {
|
||||
const user = await this.getById(userPk);
|
||||
async loadUser(userPk: User['pk'], skipErrorHandling = false) {
|
||||
const user = await this.getById(userPk, skipErrorHandling);
|
||||
|
||||
this.items = {
|
||||
...this.items,
|
||||
|
|
@ -89,7 +93,7 @@ export class UserStore extends BaseStore {
|
|||
|
||||
this.items = {
|
||||
...this.items,
|
||||
[user.pk]: user,
|
||||
[user.pk]: { ...user, timezone: getTimezone(user) },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,7 @@ import React from 'react';
|
|||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptySearchResult,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
IconButton,
|
||||
LoadingPlaceholder,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -21,23 +11,22 @@ import Collapse from 'components/Collapse/Collapse';
|
|||
import EscalationsFilters from 'components/EscalationsFilters/EscalationsFilters';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GList from 'components/GList/GList';
|
||||
import IntegrationsFilters from 'components/IntegrationsFilters/IntegrationsFilters';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import AlertReceiveChannelCard from 'containers/AlertReceiveChannelCard/AlertReceiveChannelCard';
|
||||
import EscalationChainCard from 'containers/EscalationChainCard/EscalationChainCard';
|
||||
import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainForm';
|
||||
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel';
|
||||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { SelectOption, WithStoreProps } from 'state/types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openWarningNotification } from 'utils';
|
||||
|
|
@ -48,7 +37,7 @@ const cx = cn.bind(styles);
|
|||
|
||||
interface EscalationChainsPageProps extends WithStoreProps, AppRootProps {}
|
||||
|
||||
interface EscalationChainsPageState {
|
||||
interface EscalationChainsPageState extends PageBaseState {
|
||||
escalationChainsFilters: { searchTerm: string };
|
||||
showCreateEscalationChainModal: boolean;
|
||||
escalationChainIdToCopy: EscalationChain['id'];
|
||||
|
|
@ -66,36 +55,44 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
showCreateEscalationChainModal: false,
|
||||
escalationChainIdToCopy: undefined,
|
||||
selectedEscalationChain: undefined,
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
}
|
||||
|
||||
parseQueryParams = () => {
|
||||
parseQueryParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset on query parse
|
||||
|
||||
const { store, query } = this.props;
|
||||
const { escalationChainStore } = store;
|
||||
const {
|
||||
escalationChainsFilters: { searchTerm },
|
||||
} = this.state;
|
||||
|
||||
const { escalationChainStore } = store;
|
||||
|
||||
const searchResult = escalationChainStore.getSearchResult(searchTerm);
|
||||
|
||||
let selectedEscalationChain;
|
||||
let selectedEscalationChain: EscalationChain['id'];
|
||||
if (query.id) {
|
||||
const escalationChain = escalationChainStore.items[query.id];
|
||||
let escalationChain = await escalationChainStore
|
||||
.loadItem(query.id, true)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
|
||||
if (!escalationChain) {
|
||||
return;
|
||||
}
|
||||
|
||||
escalationChain = escalationChainStore.items[query.id];
|
||||
if (escalationChain) {
|
||||
selectedEscalationChain = escalationChain.id;
|
||||
} else {
|
||||
openWarningNotification(
|
||||
`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedEscalationChain) {
|
||||
selectedEscalationChain = searchResult[0]?.id;
|
||||
}
|
||||
|
||||
this.setSelectedEscalationChain(selectedEscalationChain);
|
||||
};
|
||||
|
||||
|
|
@ -115,97 +112,111 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
update = () => {
|
||||
const { store } = this.props;
|
||||
|
||||
return store.escalationChainStore.updateItems();
|
||||
return store.escalationChainStore.updateItems('');
|
||||
};
|
||||
|
||||
componentDidUpdate() {}
|
||||
componentDidUpdate(prevProps: EscalationChainsPageProps) {
|
||||
if (this.props.query.id !== prevProps.query.id) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { store, query } = this.props;
|
||||
const {
|
||||
showCreateEscalationChainModal,
|
||||
escalationChainIdToCopy,
|
||||
escalationChainsFilters,
|
||||
selectedEscalationChain,
|
||||
errorData,
|
||||
} = this.state;
|
||||
|
||||
const { escalationChainStore } = store;
|
||||
const searchResult = escalationChainStore.getSearchResult(escalationChainsFilters.searchTerm);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
|
||||
</div>
|
||||
{!searchResult || searchResult.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New escalation chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('escalations-list')}>
|
||||
{searchResult ? (
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={selectedEscalationChain}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.setSelectedEscalationChain}
|
||||
>
|
||||
{(item) => <EscalationChainCard id={item.id} />}
|
||||
</GList>
|
||||
) : (
|
||||
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="escalation"
|
||||
pageName="escalations"
|
||||
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
|
||||
</div>
|
||||
<div className={cx('escalation')}>{this.renderEscalation()}</div>
|
||||
{!searchResult || searchResult.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New escalation chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('escalations-list')}>
|
||||
{searchResult ? (
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={selectedEscalationChain}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.setSelectedEscalationChain}
|
||||
>
|
||||
{(item) => <EscalationChainCard id={item.id} />}
|
||||
</GList>
|
||||
) : (
|
||||
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('escalation')}>{this.renderEscalation()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Escalations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tutorial
|
||||
step={TutorialStep.Escalations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateEscalationChainModal: true });
|
||||
}}
|
||||
>
|
||||
New Escalation Chain
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCreateEscalationChainModal && (
|
||||
<EscalationChainForm
|
||||
escalationChainId={escalationChainIdToCopy}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showCreateEscalationChainModal: false,
|
||||
escalationChainIdToCopy: undefined,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.handleEscalationChainCreate}
|
||||
/>
|
||||
{showCreateEscalationChainModal && (
|
||||
<EscalationChainForm
|
||||
escalationChainId={escalationChainIdToCopy}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showCreateEscalationChainModal: false,
|
||||
escalationChainIdToCopy: undefined,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.handleEscalationChainCreate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import reactStringReplace from 'react-string-replace';
|
|||
import Collapse from 'components/Collapse/Collapse';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import WrongTeamStub from 'components/NotFoundInTeam/WrongTeamStub';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import SourceCode from 'components/SourceCode/SourceCode';
|
||||
import Text from 'components/Text/Text';
|
||||
|
|
@ -58,13 +62,9 @@ const cx = cn.bind(styles);
|
|||
|
||||
interface IncidentPageProps extends WithStoreProps, AppRootProps {}
|
||||
|
||||
interface IncidentPageState {
|
||||
interface IncidentPageState extends PageBaseState {
|
||||
showIntegrationSettings?: boolean;
|
||||
showAttachIncidentForm?: boolean;
|
||||
notFound?: boolean;
|
||||
wrongTeamError?: boolean;
|
||||
wrongTeamNoPermissions?: boolean;
|
||||
teamToSwitch?: { name: string; id: string };
|
||||
timelineFilter: string;
|
||||
resolutionNoteText: string;
|
||||
}
|
||||
|
|
@ -74,8 +74,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
state: IncidentPageState = {
|
||||
timelineFilter: 'all',
|
||||
resolutionNoteText: '',
|
||||
wrongTeamError: false,
|
||||
wrongTeamNoPermissions: false,
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -93,28 +92,16 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
}
|
||||
|
||||
update = () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
store.alertGroupStore.getAlert(id).catch((error) => {
|
||||
if (error.response) {
|
||||
if (error.response.status === 404) {
|
||||
this.setState({ notFound: true });
|
||||
} else if (error.response.status === 403 && error.response.data.error_code === 'wrong_team') {
|
||||
let res = error.response.data;
|
||||
if (res.owner_team) {
|
||||
this.setState({ wrongTeamError: true, teamToSwitch: { name: res.owner_team.name, id: res.owner_team.id } });
|
||||
} else {
|
||||
this.setState({ wrongTeamError: true, wrongTeamNoPermissions: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ notFound: true });
|
||||
});
|
||||
store.alertGroupStore
|
||||
.getAlert(id)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
@ -123,53 +110,14 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
query: { id, cursor, start, perpage },
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
showIntegrationSettings,
|
||||
showAttachIncidentForm,
|
||||
notFound,
|
||||
wrongTeamError,
|
||||
teamToSwitch,
|
||||
wrongTeamNoPermissions,
|
||||
} = this.state;
|
||||
|
||||
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
|
||||
const { isNotFoundError, isWrongTeamError } = errorData;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const { alerts } = store.alertGroupStore;
|
||||
|
||||
const incident = alerts.get(id);
|
||||
const currentTeamId = store.userStore.currentUser?.current_team;
|
||||
const currentTeamName = store.grafanaTeamStore.items[currentTeamId]?.name;
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
<Text.Title level={4}>Incident not found</Text.Title>
|
||||
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
|
||||
<Button variant="secondary" icon="arrow-left" size="md">
|
||||
Go to incidents page
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (wrongTeamError) {
|
||||
return (
|
||||
<WrongTeamStub
|
||||
objectName="alert group"
|
||||
pageName="incidents"
|
||||
currentTeam={currentTeamName}
|
||||
switchToTeam={teamToSwitch}
|
||||
wrongTeamNoPermissions={wrongTeamNoPermissions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incident) {
|
||||
if (!incident && !isNotFoundError && !isWrongTeamError) {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<LoadingPlaceholder text="Loading alert group..." />
|
||||
|
|
@ -178,48 +126,75 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList id={incident.pk} getIncidentDatetimeReference={this.getIncidentDatetimeReference} />
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="alert group"
|
||||
pageName="incidents"
|
||||
>
|
||||
{() =>
|
||||
errorData.isNotFoundError ? (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('not-found')}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
<Text.Title level={4}>Incident not found</Text.Title>
|
||||
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
|
||||
<Button variant="secondary" icon="arrow-left" size="md">
|
||||
Go to incidents page
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList
|
||||
id={incident.pk}
|
||||
getIncidentDatetimeReference={this.getIncidentDatetimeReference}
|
||||
/>
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>{this.renderTimeline()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showIntegrationSettings && (
|
||||
<IntegrationSettings
|
||||
alertGroupId={incident.pk}
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
|
||||
}}
|
||||
onUpdateTemplates={() => {
|
||||
store.alertGroupStore.getAlert(id);
|
||||
}}
|
||||
startTab={IntegrationSettingsTab.Templates}
|
||||
id={incident.alert_receive_channel.id}
|
||||
onHide={() =>
|
||||
this.setState({
|
||||
showIntegrationSettings: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showAttachIncidentForm && (
|
||||
<AttachIncidentForm
|
||||
id={id}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
showAttachIncidentForm: false,
|
||||
});
|
||||
}}
|
||||
onUpdate={this.update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +272,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
Copy Link
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
<a href={incident.permalink} target="_blank" rel="noreferrer">
|
||||
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
|
||||
<Button variant="primary" size="sm" icon="slack">
|
||||
View in Slack
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ import React, { ReactElement, SyntheticEvent } from 'react';
|
|||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import CardButton from 'components/CardButton/CardButton';
|
||||
import CursorPagination from 'components/CursorPagination/CursorPagination';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
|
|
@ -21,12 +19,11 @@ import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
|||
import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types';
|
||||
import IncidentsFilters from 'containers/IncidentsFilters/IncidentsFilters';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { MaintenanceIntegration } from 'models/alert_receive_channel';
|
||||
import { Alert, Alert as AlertType, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { Alert, Alert as AlertType, AlertAction } from 'models/alertgroup/alertgroup.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { move } from 'state/helpers';
|
||||
import { SelectOption, WithStoreProps } from 'state/types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import { observer } from 'mobx-react';
|
|||
|
||||
import GList from 'components/GList/GList';
|
||||
import IntegrationsFilters, { Filters } from 'components/IntegrationsFilters/IntegrationsFilters';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import Text from 'components/Text/Text';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
|
|
@ -29,7 +34,7 @@ import styles from './Integrations.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface IntegrationsState {
|
||||
interface IntegrationsState extends PageBaseState {
|
||||
integrationsFilters: Filters;
|
||||
showCreateIntegrationModal: boolean;
|
||||
alertReceiveChannelToShowSettings?: AlertReceiveChannel['id'];
|
||||
|
|
@ -43,6 +48,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
state: IntegrationsState = {
|
||||
integrationsFilters: { searchTerm: '' },
|
||||
showCreateIntegrationModal: false,
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
alertReceiveChanneltoPoll: { [key: string]: number } = {};
|
||||
|
|
@ -58,30 +64,38 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
getLocationSrv().update({ partial: true, query: { id: alertReceiveChannelId } });
|
||||
};
|
||||
|
||||
parseQueryParams = () => {
|
||||
const { store, query } = this.props;
|
||||
parseQueryParams = async () => {
|
||||
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse // reset wrong team error to false
|
||||
|
||||
const { store, query } = this.props;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
let selectedAlertReceiveChannel = store.selectedAlertReceiveChannel;
|
||||
|
||||
if (query.id) {
|
||||
const alertReceiveChannelId = searchResult && searchResult.find((res) => res.id === query?.id)?.id;
|
||||
if (alertReceiveChannelId) {
|
||||
selectedAlertReceiveChannel = alertReceiveChannelId;
|
||||
} else {
|
||||
openWarningNotification(
|
||||
`Integration with id=${query?.id} is not found. Please select integration from the list.`
|
||||
);
|
||||
let alertReceiveChannel = await alertReceiveChannelStore
|
||||
.loadItem(query.id, true)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
|
||||
if (!alertReceiveChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alertReceiveChannel.id) {
|
||||
selectedAlertReceiveChannel = alertReceiveChannel.id;
|
||||
}
|
||||
|
||||
if (query.tab) {
|
||||
this.setState({ integrationSettingsTab: query.tab });
|
||||
this.setState({ alertReceiveChannelToShowSettings: query.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedAlertReceiveChannel) {
|
||||
selectedAlertReceiveChannel = searchResult[0]?.id;
|
||||
}
|
||||
|
||||
this.setSelectedAlertReceiveChannel(selectedAlertReceiveChannel);
|
||||
};
|
||||
|
||||
|
|
@ -104,127 +118,135 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const {
|
||||
integrationsFilters: { searchTerm },
|
||||
} = this.state;
|
||||
const { store, query } = this.props;
|
||||
const {
|
||||
integrationsFilters,
|
||||
alertReceiveChannelToShowSettings,
|
||||
integrationSettingsTab,
|
||||
showCreateIntegrationModal,
|
||||
errorData,
|
||||
} = this.state;
|
||||
|
||||
const { alertReceiveChannelStore } = store;
|
||||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
|
||||
</div>
|
||||
{searchResult?.length ? (
|
||||
<div className={cx('integrations')}>
|
||||
<div className={cx('integrationsList')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('newIntegrationButton')}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('alert-receive-channels-list')}>
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={store.selectedAlertReceiveChannel}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.handleAlertReceiveChannelSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<AlertReceiveChannelCard
|
||||
id={item.id}
|
||||
onShowHeartbeatModal={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: item.id,
|
||||
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GList>
|
||||
</div>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="integration"
|
||||
pageName="integrations"
|
||||
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('filters')}>
|
||||
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
|
||||
</div>
|
||||
<div className={cx('alert-rules', 'alertRulesBorder')}>
|
||||
<AlertRules
|
||||
alertReceiveChannelId={store.selectedAlertReceiveChannel}
|
||||
onDelete={this.handleDeleteAlertReceiveChannel}
|
||||
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
|
||||
integrationSettingsTab,
|
||||
});
|
||||
}}
|
||||
/*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler(
|
||||
{searchResult?.length ? (
|
||||
<div className={cx('integrations')}>
|
||||
<div className={cx('integrationsList')}>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('newIntegrationButton')}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
<div className={cx('alert-receive-channels-list')}>
|
||||
<GList
|
||||
autoScroll
|
||||
selectedId={store.selectedAlertReceiveChannel}
|
||||
items={searchResult}
|
||||
itemKey="id"
|
||||
onSelect={this.handleAlertReceiveChannelSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<AlertReceiveChannelCard
|
||||
id={item.id}
|
||||
onShowHeartbeatModal={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: item.id,
|
||||
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GList>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('alert-rules', 'alertRulesBorder')}>
|
||||
<AlertRules
|
||||
alertReceiveChannelId={store.selectedAlertReceiveChannel}
|
||||
onDelete={this.handleDeleteAlertReceiveChannel}
|
||||
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
|
||||
integrationSettingsTab,
|
||||
});
|
||||
}}
|
||||
/*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler(
|
||||
store.selectedAlertReceiveChannel
|
||||
)}*/
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<Tutorial
|
||||
step={TutorialStep.Integrations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<Tutorial
|
||||
step={TutorialStep.Integrations}
|
||||
title={
|
||||
<VerticalGroup align="center" spacing="lg">
|
||||
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
|
||||
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
)}
|
||||
</div>
|
||||
{alertReceiveChannelToShowSettings && (
|
||||
<IntegrationSettings
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
|
||||
}}
|
||||
startTab={integrationSettingsTab}
|
||||
id={alertReceiveChannelToShowSettings}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: undefined,
|
||||
integrationSettingsTab: undefined,
|
||||
});
|
||||
getLocationSrv().update({ partial: true, query: { tab: undefined } });
|
||||
}}
|
||||
/>
|
||||
{alertReceiveChannelToShowSettings && (
|
||||
<IntegrationSettings
|
||||
onUpdate={() => {
|
||||
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
|
||||
}}
|
||||
startTab={integrationSettingsTab}
|
||||
id={alertReceiveChannelToShowSettings}
|
||||
onHide={() => {
|
||||
this.setState({
|
||||
alertReceiveChannelToShowSettings: undefined,
|
||||
integrationSettingsTab: undefined,
|
||||
});
|
||||
getLocationSrv().update({ partial: true, query: { tab: undefined } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCreateIntegrationModal && (
|
||||
<CreateAlertReceiveChannelContainer
|
||||
onHide={() => {
|
||||
this.setState({ showCreateIntegrationModal: false });
|
||||
}}
|
||||
onCreate={this.handleCreateNewAlertReceiveChannel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showCreateIntegrationModal && (
|
||||
<CreateAlertReceiveChannelContainer
|
||||
onHide={() => {
|
||||
this.setState({ showCreateIntegrationModal: false });
|
||||
}}
|
||||
onCreate={this.handleCreateNewAlertReceiveChannel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ import cn from 'classnames/bind';
|
|||
import { observer } from 'mobx-react';
|
||||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookForm';
|
||||
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
|
||||
import { ActionDTO } from 'models/action';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { UserAction } from 'state/userAction';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
|
@ -26,13 +29,15 @@ const cx = cn.bind(styles);
|
|||
|
||||
interface OutgoingWebhooksProps extends WithStoreProps, AppRootProps {}
|
||||
|
||||
interface OutgoingWebhooksState {
|
||||
interface OutgoingWebhooksState extends PageBaseState {
|
||||
outgoingWebhookIdToEdit?: OutgoingWebhook['id'] | 'new';
|
||||
}
|
||||
|
||||
@observer
|
||||
class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWebhooksState> {
|
||||
state: OutgoingWebhooksState = {};
|
||||
state: OutgoingWebhooksState = {
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
this.update().then(this.parseQueryParams);
|
||||
|
|
@ -44,27 +49,42 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
}
|
||||
}
|
||||
|
||||
parseQueryParams = () => {
|
||||
parseQueryParams = async () => {
|
||||
this.setState((prevState) => ({
|
||||
errorData: initErrorDataState(),
|
||||
outgoingWebhookIdToEdit: undefined,
|
||||
})); // reset state on query parse
|
||||
|
||||
const {
|
||||
store,
|
||||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
if (id) {
|
||||
if (!id) {return;}
|
||||
|
||||
let outgoingWebhook: OutgoingWebhook | void = undefined;
|
||||
const isNewWebhook = id === 'new';
|
||||
|
||||
if (!isNewWebhook) {
|
||||
outgoingWebhook = await store.outgoingWebhookStore
|
||||
.loadItem(id, true)
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
}
|
||||
|
||||
if (outgoingWebhook || isNewWebhook) {
|
||||
this.setState({ outgoingWebhookIdToEdit: id });
|
||||
}
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
const { selectedAlertReceiveChannel } = store;
|
||||
|
||||
return store.outgoingWebhookStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { outgoingWebhookIdToEdit } = this.state;
|
||||
const { store, query } = this.props;
|
||||
const { outgoingWebhookIdToEdit, errorData } = this.state;
|
||||
|
||||
const webhooks = store.outgoingWebhookStore.getSearchResult();
|
||||
|
||||
|
|
@ -87,39 +107,48 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
objectName="outgoing webhook"
|
||||
pageName="outgoing_webhooks"
|
||||
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
<div className={cx('header')}>
|
||||
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
|
||||
<PluginLink
|
||||
partial
|
||||
query={{ id: 'new' }}
|
||||
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
|
||||
>
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button variant="primary" icon="plus">
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControl>
|
||||
</PluginLink>
|
||||
</div>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={webhooks}
|
||||
/>
|
||||
</div>
|
||||
{outgoingWebhookIdToEdit && (
|
||||
<OutgoingWebhookForm
|
||||
id={outgoingWebhookIdToEdit}
|
||||
onUpdate={this.update}
|
||||
onHide={this.handleOutgoingWebhookFormHide}
|
||||
/>
|
||||
)}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={webhooks}
|
||||
/>
|
||||
</div>
|
||||
{outgoingWebhookIdToEdit && (
|
||||
<OutgoingWebhookForm
|
||||
id={outgoingWebhookIdToEdit}
|
||||
onUpdate={this.update}
|
||||
onHide={this.handleOutgoingWebhookFormHide}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</PageErrorHandlingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,30 +7,10 @@ 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 | DateTime, timezone: Timezone) => {
|
||||
const browserTimezone = dayjs.tz.guess();
|
||||
|
||||
const browserTimezoneOffset = dayjs().tz(browserTimezone).utcOffset();
|
||||
const timezoneOffset = dayjs().tz(timezone).utcOffset();
|
||||
|
||||
return (moment as dayjs.Dayjs)
|
||||
.clone()
|
||||
.utc()
|
||||
.add(browserTimezoneOffset, 'minutes') // we need these calculations because we can't specify timezone for DateTimePicker directly
|
||||
.subtract(timezoneOffset, 'minutes')
|
||||
.format('YYYY-MM-DDTHH:mm:ss.000Z');
|
||||
export const getUTCString = (moment: dayjs.Dayjs) => {
|
||||
return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
|
||||
};
|
||||
|
||||
export const getDateTime = (date: string, timezone: Timezone) => {
|
||||
const browserTimezone = dayjs.tz.guess();
|
||||
|
||||
const browserTimezoneOffset = dayjs().tz(browserTimezone).utcOffset();
|
||||
const timezoneOffset = dayjs().tz(timezone).utcOffset();
|
||||
|
||||
return dateTime(
|
||||
dayjs(date)
|
||||
.subtract(browserTimezoneOffset, 'minutes')
|
||||
.add(timezoneOffset, 'minutes')
|
||||
.format('YYYY-MM-DDTHH:mm:ss.000Z')
|
||||
);
|
||||
export const getDateTime = (date: string) => {
|
||||
return dayjs(date);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
margin-top: 24px;
|
||||
|
||||
--rotations-border: var(--border-medium);
|
||||
--rotations-background: var(--primary-background);
|
||||
--rotations-background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@ import React, { useMemo } from 'react';
|
|||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton, Icon } from '@grafana/ui';
|
||||
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';
|
||||
|
|
@ -20,6 +29,7 @@ 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';
|
||||
|
|
@ -36,6 +46,8 @@ 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
|
||||
|
|
@ -50,6 +62,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
startMoment: getStartOfWeek(store.currentTimezone),
|
||||
schedulePeriodType: 'week',
|
||||
renderType: 'timeline',
|
||||
shiftIdToShowRotationForm: undefined,
|
||||
shiftIdToShowOverridesForm: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -74,9 +88,16 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
this.updateEvents();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { store } = this.props;
|
||||
|
||||
store.scheduleStore.clearPreview();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { startMoment, schedulePeriodType, renderType } = this.state;
|
||||
const { startMoment, schedulePeriodType, renderType, shiftIdToShowRotationForm, shiftIdToShowOverridesForm } =
|
||||
this.state;
|
||||
const { query } = this.props;
|
||||
const { id: scheduleId } = query;
|
||||
|
||||
|
|
@ -95,7 +116,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
<PluginLink query={{ page: 'schedules-new' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
|
||||
</PluginLink>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={3} onTextChange={this.handleNameChange}>
|
||||
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{/*<ScheduleCounter
|
||||
|
|
@ -121,9 +142,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
{users && (
|
||||
<UserTimezoneSelect value={currentTimezone} users={users} onChange={this.handleTimezoneChange} />
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Current timezone:</Text>
|
||||
<UserTimezoneSelect value={currentTimezone} users={users} onChange={this.handleTimezoneChange} />
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<ScheduleQuality quality={0.89} />
|
||||
{/*<ScheduleQuality quality={0.89} />*/}
|
||||
{/*<ToolbarButton icon="copy" tooltip="Copy" />
|
||||
<ToolbarButton icon="brackets-curly" tooltip="Code" />
|
||||
<ToolbarButton icon="share-alt" tooltip="Share" />
|
||||
|
|
@ -162,9 +186,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<div>
|
||||
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
|
||||
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
|
||||
</div>
|
||||
</Text.Title>
|
||||
</HorizontalGroup>
|
||||
{/*<HorizontalGroup width="auto">
|
||||
<RadioButtonGroup
|
||||
|
|
@ -196,7 +220,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
</div>
|
||||
{/* <div className={'current-time'} />*/}
|
||||
<div className={cx('rotations')}>
|
||||
<ScheduleFinal scheduleId={scheduleId} currentTimezone={currentTimezone} startMoment={startMoment} />
|
||||
<ScheduleFinal
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
startMoment={startMoment}
|
||||
onClick={this.handleShowForm}
|
||||
/>
|
||||
<Rotations
|
||||
scheduleId={scheduleId}
|
||||
currentTimezone={currentTimezone}
|
||||
|
|
@ -204,6 +233,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
onCreate={this.handleCreateRotation}
|
||||
onUpdate={this.handleUpdateRotation}
|
||||
onDelete={this.handleDeleteRotation}
|
||||
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
|
||||
onShowRotationForm={this.handleShowRotationForm}
|
||||
/>
|
||||
<ScheduleOverrides
|
||||
scheduleId={scheduleId}
|
||||
|
|
@ -212,6 +243,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
onCreate={this.handleCreateOverride}
|
||||
onUpdate={this.handleUpdateOverride}
|
||||
onDelete={this.handleDeleteOverride}
|
||||
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
|
||||
onShowRotationForm={this.handleShowOverridesForm}
|
||||
/>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
|
|
@ -219,6 +252,28 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue