diff --git a/.env.example b/.env.dev.example similarity index 100% rename from .env.example rename to .env.dev.example diff --git a/.gitignore b/.gitignore index b00b88a2..308f671f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ */db.sqlite3 *.pyc venv +.python-version .env .env_hobby +.env.dev .vscode dump.rdb .idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce5d9b7..ebfa6fb2 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/DEVELOPER.md b/DEVELOPER.md index 6612a8e7..fd71c96b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -1,22 +1,22 @@ -* [Developer quickstart](#developer-quickstart) - * [Code style](#code-style) - * [Backend setup](#backend-setup) - * [Frontend setup](#frontend-setup) - * [Slack application setup](#slack-application-setup) - * [Update drone build](#update-drone-build) -* [Troubleshooting](#troubleshooting) - * [ld: library not found for -lssl](#ld-library-not-found-for--lssl) - * [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly) - * [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value-) - * [Empty queryset when filtering against datetime field](#empty-queryset-when-filtering-against-datetime-field) -* [Hints](#hints) - * [Building the all-in-one docker container](#building-the-all-in-one-docker-container) - * [Running Grafana with plugin (frontend) folder mounted for dev purposes](#running-grafana-with-plugin-frontend-folder-mounted-for-dev-purposes) - * [How to recreate the local database](#recreating-the-local-database) - * [Running tests locally](#running-tests-locally) -* [IDE Specific Instructions](#ide-specific-instructions) - * [PyCharm](#pycharm) - +- [Developer quickstart](#developer-quickstart) + - [Code style](#code-style) + - [Backend setup](#backend-setup) + - [Frontend setup](#frontend-setup) + - [Slack application setup](#slack-application-setup) + - [Update drone build](#update-drone-build) +- [Troubleshooting](#troubleshooting) + - [ld: library not found for -lssl](#ld-library-not-found-for--lssl) + - [Could not build wheels for cryptography which use PEP 517 and cannot be installed directly](#could-not-build-wheels-for-cryptography-which-use-pep-517-and-cannot-be-installed-directly) + - [django.db.utils.OperationalError: (1366, "Incorrect string value ...")](#djangodbutilsoperationalerror-1366-incorrect-string-value-) + - [Empty queryset when filtering against datetime field](#empty-queryset-when-filtering-against-datetime-field) +- [Hints](#hints) + - [Building the all-in-one docker container](#building-the-all-in-one-docker-container) + - [Running Grafana with plugin (frontend) folder mounted for dev purposes](#running-grafana-with-plugin-frontend-folder-mounted-for-dev-purposes) + - [How to recreate the local database](#recreating-the-local-database) + - [Running tests locally](#running-tests-locally) +- [IDE Specific Instructions](#ide-specific-instructions) + - [PyCharm](#pycharm) + ## Developer quickstart ### Code style @@ -29,13 +29,23 @@ ### Backend setup 1. Start stateful services (RabbitMQ, Redis, Grafana with mounted plugin folder) + ```bash docker-compose -f docker-compose-developer.yml up -d ``` NOTE: to use a PostgreSQL db backend, use the `docker-compose-developer-pg.yml` file instead. -2. Prepare a python environment: +2. `postgres` is a dependency on some of our Python dependencies (notably `psycopg2` ([docs](https://www.psycopg.org/docs/install.html#prerequisites))). To install this on Mac you can simply run: + +```bash +brew install postgresql@14 +``` + +For non Mac installation please visit [here](https://www.postgresql.org/download/) for more information on how to install. + +3. Prepare a python environment: + ```bash # Create and activate the virtual environment python3.9 -m venv venv && source venv/bin/activate @@ -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 @@ -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 diff --git a/README.md b/README.md index f6c0e447..51eaa6b1 100644 --- a/README.md +++ b/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 - ## Stargazers over time [![Stargazers over time](https://starchart.cc/grafana/oncall.svg)](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) diff --git a/docker-compose-developer-pg.yml b/docker-compose-developer-pg.yml index f6f813f2..f42f17e3 100644 --- a/docker-compose-developer-pg.yml +++ b/docker-compose-developer-pg.yml @@ -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 diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index dc2f1179..33ef3fd1 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9caaac8a..a77f5d25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index 59b27ead..8e90f6d3 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -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" + } } ] } diff --git a/engine/apps/alerts/incident_appearance/renderers/email_renderer.py b/engine/apps/alerts/incident_appearance/renderers/email_renderer.py index 5107988b..eb18e190 100644 --- a/engine/apps/alerts/incident_appearance/renderers/email_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/email_renderer.py @@ -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, diff --git a/engine/apps/alerts/incident_appearance/renderers/sms_renderer.py b/engine/apps/alerts/incident_appearance/renderers/sms_renderer.py index c050e4ee..13924771 100644 --- a/engine/apps/alerts/incident_appearance/renderers/sms_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/sms_renderer.py @@ -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 diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index db079c16..cb08e8e1 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -1,4 +1,5 @@ import logging +import typing from collections import namedtuple from typing import Optional from urllib.parse import urljoin @@ -9,7 +10,7 @@ from celery import uuid as celery_uuid from django.apps import apps from django.conf import settings from django.core.validators import MinLengthValidator -from django.db import IntegrityError, models +from django.db import IntegrityError, models, transaction from django.db.models import JSONField, Q, QuerySet from django.utils import timezone from django.utils.functional import cached_property @@ -45,6 +46,10 @@ def generate_public_primary_key_for_alert_group(): return new_public_primary_key +class Permalinks(typing.TypedDict): + slack: str + + class AlertGroupQuerySet(models.QuerySet): def create(self, **kwargs): organization = kwargs["channel"].organization @@ -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() diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index 1a261880..a23f2a4b 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -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 diff --git a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py index d6cb6398..eae19ef3 100644 --- a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py @@ -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 diff --git a/engine/apps/alerts/tests/test_utils.py b/engine/apps/alerts/tests/test_utils.py index ff19018a..7934f64a 100644 --- a/engine/apps/alerts/tests/test_utils.py +++ b/engine/apps/alerts/tests/test_utils.py @@ -12,3 +12,11 @@ def test_request_outgoing_webhook_cannot_resolve_name(): success, err = request_outgoing_webhook("http://something.something/webhook", "GET") assert success is False assert err == "Cannot resolve name in url" + + +@pytest.mark.django_db +def test_request_outgoing_webhook_resolve_name_without_port(): + with patch("apps.alerts.utils.socket.gethostbyname") as mock_gethostbyname: + mock_gethostbyname.return_value = "127.0.0.1" + request_outgoing_webhook("http://something.something:9000/webhook", "GET") + assert mock_gethostbyname.call_args_list[0].args[0] == "something.something" diff --git a/engine/apps/alerts/utils.py b/engine/apps/alerts/utils.py index 58ba22ea..86cbc786 100644 --- a/engine/apps/alerts/utils.py +++ b/engine/apps/alerts/utils.py @@ -57,7 +57,7 @@ def request_outgoing_webhook(webhook_url, http_request_type, post_kwargs={}) -> if not live_settings.DANGEROUS_WEBHOOKS_ENABLED: # Get the ip address of the webhook url and check if it belongs to the private network try: - webhook_url_ip_address = socket.gethostbyname(parsed_url.netloc) + webhook_url_ip_address = socket.gethostbyname(parsed_url.hostname) except socket.gaierror: return False, "Cannot resolve name in url" if not live_settings.DANGEROUS_WEBHOOKS_ENABLED: diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index f9ecf443..a71cfde2 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -132,7 +132,7 @@ class AlertGroupSerializer(AlertGroupListSerializer): fields = AlertGroupListSerializer.Meta.fields + [ "alerts", "render_after_resolve_report_json", - "permalink", + "slack_permalink", "last_alert_at", ] diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py index 3c358c90..bc91fc60 100644 --- a/engine/apps/api/tests/test_custom_button.py +++ b/engine/apps/api/tests/test_custom_button.py @@ -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 diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index ea3a6dc0..13ef0825 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -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 diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index aae7360f..40df30d8 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -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)) diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 6dce1c56..866620a8 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -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, diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py index 0a9f1973..8d2a8082 100644 --- a/engine/apps/api/views/custom_button.py +++ b/engine/apps/api/views/custom_button.py @@ -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 diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index f972a992..72c73d3a 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -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) diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index ece5186c..4bc5764e 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -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: diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 219501f7..cf37b9e7 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 00153a87..0fe0d8a9 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -2,7 +2,9 @@ import json import re from urllib.parse import urlparse +import phonenumbers from django.apps import apps +from phonenumbers import NumberParseException from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot @@ -125,7 +127,11 @@ class LiveSettingValidator: @staticmethod def _is_phone_number_valid(phone_number): - return re.match(r"^\+\d{11}$", phone_number) + try: + ph_num = phonenumbers.parse(phone_number) + return phonenumbers.is_valid_number(ph_num) + except NumberParseException: + return False @staticmethod def _prettify_twilio_error(exc): diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/incidents.py index 1d3bc174..00ec64f6 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/incidents.py @@ -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): diff --git a/engine/apps/public_api/tests/test_incidents.py b/engine/apps/public_api/tests/test_incidents.py index ea1198a0..45a5d115 100644 --- a/engine/apps/public_api/tests/test_incidents.py +++ b/engine/apps/public_api/tests/test_incidents.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import patch import pytest from django.urls import reverse @@ -38,6 +39,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): diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 620e4450..ac8f9596 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -134,7 +134,7 @@ def list_of_oncall_shifts_from_ical( continue tmp_result_datetime, tmp_result_date = get_shifts_dict( - calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts + calendar, calendar_type, schedule, datetime_start, datetime_end, with_empty_shifts ) result_datetime.extend(tmp_result_datetime) result_date.extend(tmp_result_date) @@ -161,7 +161,7 @@ def list_of_oncall_shifts_from_ical( return result or None -def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts=False): +def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, with_empty_shifts=False): events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end) result_datetime = [] result_date = [] @@ -175,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: diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 3c7c41e9..db9ca5d3 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -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) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 5af43797..24aa8d0d 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -3,6 +3,7 @@ import functools import itertools import icalendar +import pytz from django.apps import apps from django.conf import settings from django.core.validators import MinLengthValidator @@ -254,12 +255,20 @@ class OnCallSchedule(PolymorphicModel): if not events: return [] + def event_start_cmp_key(e): + # all day events: compare using a datetime object at 00:00 + start = e["start"] + if not isinstance(start, datetime.datetime): + start = datetime.datetime.combine(start, datetime.datetime.min.time(), tzinfo=pytz.UTC) + return start + def event_cmp_key(e): """Sorting key criteria for events.""" + start = event_start_cmp_key(e) return ( -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None -e["priority_level"] if e["priority_level"] else 0, - e["start"], + start, ) def insort_event(eventlist, e): @@ -314,7 +323,7 @@ class OnCallSchedule(PolymorphicModel): if ev["priority_level"] != current_priority: # update scheduled intervals on priority change # and start from the beginning for the new priority level - resolved.sort(key=lambda e: e["start"]) + resolved.sort(key=event_start_cmp_key) intervals = _merge_intervals(resolved) current_interval_idx = 0 current_priority = ev["priority_level"] @@ -367,7 +376,7 @@ class OnCallSchedule(PolymorphicModel): # TODO: switch to bisect insert on python 3.10 (or consider heapq) insort_event(pending, ev) - resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) + resolved.sort(key=lambda e: (event_start_cmp_key(e), e["shift"]["pk"] or "")) return resolved def _merge_events(self, events): @@ -645,6 +654,7 @@ class OnCallScheduleWeb(OnCallSchedule): for g in rolling_groups if g is not None ), + set(), ) return users diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index dd3cfba6..4dbf6029 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -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, diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 38213be8..f6bc2155 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -1,9 +1,16 @@ +import datetime from uuid import uuid4 import pytest +import pytz from django.utils import timezone -from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical +from apps.schedules.ical_utils import ( + list_of_oncall_shifts_from_ical, + list_users_to_notify_from_ical, + parse_event_uid, + users_in_ical, +) from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar from common.constants.role import Role @@ -63,6 +70,29 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( assert set(users_on_call) == {user} +@pytest.mark.django_db +def test_shifts_dict_all_day_middle_event(make_organization, make_schedule, get_ical): + calendar = get_ical("calendar_with_all_day_event.ics") + organization = make_organization() + schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) + schedule.cached_ical_file_primary = calendar.to_ical() + + day_to_check_iso = "2021-01-27T15:27:14.448059+00:00" + parsed_iso_day_to_check = datetime.datetime.fromisoformat(day_to_check_iso).replace(tzinfo=pytz.UTC) + requested_date = (parsed_iso_day_to_check - timezone.timedelta(days=1)).date() + shifts = list_of_oncall_shifts_from_ical(schedule, requested_date, days=3, with_empty_shifts=True) + assert len(shifts) == 4 + for s in shifts: + start = s["start"].date() if isinstance(s["start"], datetime.datetime) else s["start"] + end = s["end"].date() if isinstance(s["end"], datetime.datetime) else s["end"] + # event started in the given period, or ended in that period, or is happening during the period + assert ( + requested_date <= start <= requested_date + timezone.timedelta(days=3) + or requested_date <= end <= requested_date + timezone.timedelta(days=3) + or start <= requested_date <= end + ) + + def test_parse_event_uid_v1(): uuid = uuid4() event_uid = f"amixr-{uuid}-U1-E2-S1" diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index f46bb4b2..8bb079f4 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -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) diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 109577b1..91814f6d 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -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}" diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index cec692c6..c22a14ad 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -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() diff --git a/engine/common/api_helpers/paginators.py b/engine/common/api_helpers/paginators.py index 01ce2cc6..2a3ad974 100644 --- a/engine/common/api_helpers/paginators.py +++ b/engine/common/api_helpers/paginators.py @@ -1,19 +1,33 @@ from rest_framework.pagination import CursorPagination, PageNumberPagination +from common.api_helpers.utils import create_engine_url -class HundredPageSizePaginator(PageNumberPagination): + +class PathPrefixedPagination(PageNumberPagination): + def paginate_queryset(self, queryset, request, view=None): + request.build_absolute_uri = lambda: create_engine_url(request.get_full_path()) + return super().paginate_queryset(queryset, request, view) + + +class PathPrefixedCursorPagination(CursorPagination): + def paginate_queryset(self, queryset, request, view=None): + request.build_absolute_uri = lambda: create_engine_url(request.get_full_path()) + return super().paginate_queryset(queryset, request, view) + + +class HundredPageSizePaginator(PathPrefixedPagination): page_size = 100 -class FiftyPageSizePaginator(PageNumberPagination): +class FiftyPageSizePaginator(PathPrefixedPagination): page_size = 50 -class TwentyFivePageSizePaginator(PageNumberPagination): +class TwentyFivePageSizePaginator(PathPrefixedPagination): page_size = 25 -class TwentyFiveCursorPaginator(CursorPagination): +class TwentyFiveCursorPaginator(PathPrefixedCursorPagination): page_size = 25 max_page_size = 100 page_size_query_param = "perpage" diff --git a/engine/config_integrations/alertmanager.py b/engine/config_integrations/alertmanager.py index bfdcff2e..cc356e26 100644 --- a/engine/config_integrations/alertmanager.py +++ b/engine/config_integrations/alertmanager.py @@ -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 %}
Click here to open contact point, and diff --git a/engine/config_integrations/grafana_alerting.py b/engine/config_integrations/grafana_alerting.py index e8942b1e..4eac0135 100644 --- a/engine/config_integrations/grafana_alerting.py +++ b/engine/config_integrations/grafana_alerting.py @@ -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 %}
Click here to open contact point, and diff --git a/engine/settings/dev.py b/engine/settings/dev.py index 87a82d27..fb7ddc3e 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -9,6 +9,8 @@ if DB_BACKEND == "mysql": # noqa pymysql.install_as_MySQLdb() +DEBUG = True + DATABASES = { "default": { "ENGINE": "django.db.backends.{}".format(DB_BACKEND), # noqa diff --git a/engine/settings/helm.py b/engine/settings/helm.py index 3f9f1a62..00fa96c3 100644 --- a/engine/settings/helm.py +++ b/engine/settings/helm.py @@ -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": { diff --git a/grafana-plugin/.nvmrc b/grafana-plugin/.nvmrc new file mode 100644 index 00000000..62df50f1 --- /dev/null +++ b/grafana-plugin/.nvmrc @@ -0,0 +1 @@ +14.17.0 diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md index ffaeb731..ebfa6fb2 100644 --- a/grafana-plugin/CHANGELOG.md +++ b/grafana-plugin/CHANGELOG.md @@ -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. diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 286bc509..4e3f6467 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -154,7 +154,7 @@ export const Root = observer((props: AppRootProps) => { return ( - + ); diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css index a117b44d..683a78ad 100644 --- a/grafana-plugin/src/components/Modal/Modal.module.css +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -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; } diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx index 146a9df8..869a3a25 100644 --- a/grafana-plugin/src/components/Modal/Modal.tsx +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -26,6 +26,7 @@ const Modal: FC> = (props) => { return ( { + return { isWrongTeamError: false, wrongTeamNoPermissions: false }; +} + +export function getWrongTeamResponseInfo({ response }): Partial { + 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 }; +} diff --git a/grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.module.css b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.module.css similarity index 83% rename from grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.module.css rename to grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.module.css index d158a511..70b01cd0 100644 --- a/grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.module.css +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.module.css @@ -13,7 +13,3 @@ margin-right: 4px; padding-top: 6px; } - -.return-to-list { - margin-top: 32px; -} diff --git a/grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.tsx b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx similarity index 61% rename from grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.tsx rename to grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx index c28d8eb8..5d0445ba 100644 --- a/grafana-plugin/src/components/NotFoundInTeam/WrongTeamStub.tsx +++ b/grafana-plugin/src/components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.tsx @@ -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 = (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 = (props) => { Change the team )} - + Or return to the {objectName} list for team {currentTeam} ); -}; - -export default WrongTeamStub; +} diff --git a/grafana-plugin/src/components/PluginLink/PluginLink.tsx b/grafana-plugin/src/components/PluginLink/PluginLink.tsx index eef5374e..3a218c2e 100644 --- a/grafana-plugin/src/components/PluginLink/PluginLink.tsx +++ b/grafana-plugin/src/components/PluginLink/PluginLink.tsx @@ -11,7 +11,7 @@ interface PluginLinkProps extends LocationUpdate { disabled?: boolean; className?: string; wrap?: boolean; - children: any + children: any; } const cx = cn.bind(styles); diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 1d2b5ce1..0a8d609b 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -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 - ); @@ -319,6 +316,7 @@ export class EscalationPolicy extends React.Component ); diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css index df6caa08..50e50ffc 100644 --- a/grafana-plugin/src/components/Table/Table.module.css +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -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; diff --git a/grafana-plugin/src/components/Table/Table.tsx b/grafana-plugin/src/components/Table/Table.tsx index d639ccf0..dbc5e652 100644 --- a/grafana-plugin/src/components/Table/Table.tsx +++ b/grafana-plugin/src/components/Table/Table.tsx @@ -51,7 +51,7 @@ const GTable: FC = (props) => { { clearBeforeEdit = false, hidden = false, editModalTitle = 'New value', + style, } = props; const [isEditMode, setIsEditMode] = useState(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 = (props) => { - const { level, className, ...restProps } = props; + const { level, className, style, ...restProps } = props; // @ts-ignore const Tag: keyof JSX.IntrinsicElements = `h${level}`; return ( - + ); diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css index e4cb9d63..ccae5a02 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css @@ -8,7 +8,6 @@ font-weight: 400; font-size: 12px; line-height: 16px; - color: rgba(204, 204, 220, 0.65); pointer-events: none; } diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index 56bf4fe3..1ae46c1e 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -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 = (props) => { {momentsToRender.map((m, i) => { return (
-
{m.moment.format('D MMM')}
+
+ {m.moment.format('ddd D MMM')} +
{m.moments.map((mm, j) => (
@@ -69,7 +73,7 @@ const TimelineMarks: FC = (props) => { 'weekday-time-title__hidden': i === 0 && j === 0, })} > - {mm.format('HH:mm')} + {mm.format('HH:mm')}
))} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 178e3412..d48834b8 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -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 { diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index 22790683..e7953645 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -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 = () => ; +const DragHandle = () => ; const SortableHandleHoc = SortableHandle(DragHandle); @@ -94,7 +95,7 @@ const UserGroups = (props: UserGroupsProps) => { {renderUser(item.data)}
- +
@@ -104,6 +105,16 @@ const UserGroups = (props: UserGroupsProps) => { return (
+ { isMultipleGroups={isMultipleGroups} useDragHandle /> -
); @@ -154,14 +156,16 @@ const SortableList = SortableContainer(({ items, handleAddGro ) : isMultipleGroups ? ( -
  • {item.data.name}
  • +
  • + {item.data.name} +
  • ) : null )} {isMultipleGroups && items[items.length - 1]?.type === 'item' && (
  • - Add user group + + Add user group +
  • )} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index 453c578b..83a4882f 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -20,46 +20,77 @@ interface UserTimezoneSelectProps { const cx = cn.bind(styles); const UserTimezoneSelect: FC = (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 (
    -
    ); }; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index cf25e5dd..39b925f1 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -25,7 +25,7 @@ interface WorkingHoursProps { const cx = cn.bind(styles); const WorkingHours: FC = (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'); diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 5c4d5996..10f48d9d 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -804,8 +804,8 @@ class AlertRules extends React.Component { alertReceiveChannelStore.updateCounters(); openNotification(
    - Demo alert was generated. Find it in the - "Incidents" + Demo alert was generated. Find it on the + "Alert Groups" page and make sure it didn't freak out your colleagues 😉
    ); @@ -821,8 +821,8 @@ class AlertRules extends React.Component { alertReceiveChannelStore.sendDemoAlertToParticularRoute(id).then(() => { openNotification(
    - Demo alert was generated. Find it in the - "Incidents" + Demo alert was generated. Find it on the + "Alert Groups" page and make sure it didn't freak out your colleagues 😉
    ); diff --git a/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenForm.tsx b/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenForm.tsx index f0f98b74..cf471d17 100644 --- a/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenForm.tsx +++ b/grafana-plugin/src/containers/ApiTokenSettings/ApiTokenForm.tsx @@ -100,14 +100,14 @@ const ApiTokenForm = observer((props: TokenCreationModalProps) => { return ( - {getCurlExample(token)} + {getCurlExample(token, store.onCallApiUrl)} ); } }); -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; diff --git a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx index 062b9b8d..8f9174aa 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -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]; diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index a41c31ce..07e8c562 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -32,6 +32,7 @@ interface GSelectProps { showWarningIfEmptyValue?: boolean; showError?: boolean; nullItemName?: string; + fromOrganization?: boolean; filterOptions?: (id: any) => boolean; dropdownRender?: (menu: ReactElement) => ReactElement; getOptionLabel?: (item: SelectableValue) => 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]); diff --git a/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx b/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx index 1ca31a27..b2984341 100644 --- a/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx +++ b/grafana-plugin/src/containers/GrafanaTeamSelect/GrafanaTeamSelect.tsx @@ -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] diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.ts b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.ts index 76fecf4f..836f1bcf 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.ts +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.ts @@ -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: { diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 8385d0f8..76891c47 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -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'; diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 264315b3..e199bf02 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -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(true); const [retrySync, setRetrySync] = useState(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) => { )}

    {'Plugin <-> backend connection status'}

    -            {pluginStatusMessage}
    +            {pluginStatusMessage}
               
    - {/*

    {'Plugin <-> backend connection status'}

    -
    -                {pluginStatusMessage}
    -              
    */} {retrySync && (
    + + + } placeholder="Search..." @@ -98,6 +103,7 @@ class ScheduleFinal extends Component ); @@ -119,6 +125,14 @@ class ScheduleFinal extends Component { + const { onClick } = this.props; + + return () => { + onClick(shiftId); + }; + }; + onSearchTermChangeCallback = () => {}; } diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 4e3bd0e0..f6ba5f3b 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -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 { 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
    -
    Overrides
    +
    + + Overrides + +
    @@ -114,10 +120,10 @@ class ScheduleOverrides extends Component*/}
    - {shiftIdToShowOverrideForm && ( + {shiftIdToShowRotationForm && ( { - 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); }; } diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 406bef15..374b0e3c 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -166,8 +166,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { - {dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')} - {dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')} + {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')} diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css index 0eab0c54..94166b47 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css @@ -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%; diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 07103b78..59b6edaa 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -32,9 +32,6 @@ const UsersTimezones: FC = (props) => { const store = useStore(); - const [count, setCount] = useState(0); - const [currentMoment, setCurrentMoment] = useState(dayjs().tz(tz)); - useEffect(() => { userIds.forEach((userId) => { if (!store.userStore.items[userId]) { @@ -48,18 +45,7 @@ const UsersTimezones: FC = (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 = (props) => {
    -
    Schedule team and timezones
    +
    + + Schedule team and timezones + +
    {/* Current schedule users only @@ -112,12 +102,18 @@ const UsersTimezones: FC = (props) => { 'time-mark-text__translated': index > 0, })} > - {mm.format('HH:mm')} + + {mm.format('HH:mm')} +
    ))}
    - 24:00 + + + 24:00 + +
    diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index ffc6f75c..ff0afb08 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -238,7 +238,7 @@ export const ExpandIcon = (props: IconProps) => { diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index 4e3ef3da..ab8808cf 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -70,6 +70,18 @@ export class AlertReceiveChannelStore extends BaseStore { ); } + @action + async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise { + 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); diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index d704c34e..d7e55b4f 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -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[] }; diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index ab46fe3c..f20c4d6b 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -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 diff --git a/grafana-plugin/src/models/escalation_chain/escalation_chain.ts b/grafana-plugin/src/models/escalation_chain/escalation_chain.ts index b0e7a4e7..c6e19968 100644 --- a/grafana-plugin/src/models/escalation_chain/escalation_chain.ts +++ b/grafana-plugin/src/models/escalation_chain/escalation_chain.ts @@ -22,6 +22,18 @@ export class EscalationChainStore extends BaseStore { this.path = '/escalation_chains/'; } + @action + async loadItem(id: EscalationChain['id'], skipErrorHandling = false): Promise { + 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); diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts index ab70799a..f8a435c7 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts @@ -19,6 +19,18 @@ export class OutgoingWebhookStore extends BaseStore { this.path = '/custom_buttons/'; } + @action + async loadItem(id: OutgoingWebhook['id'], skipErrorHandling = false): Promise { + 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, diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index bb76853a..fbb1e533 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -82,6 +82,18 @@ export class ScheduleStore extends BaseStore { this.path = '/schedules/'; } + @action + async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise { + 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']) { diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index fde7d136..ff36079e 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -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; } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 4bf99254..0079d795 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -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) }, }; } diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 42920ba7..9b4109ed 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -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 { + 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 { 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 ( - <> -
    -
    - -
    - {!searchResult || searchResult.length ? ( -
    -
    - - - -
    - {searchResult ? ( - - {(item) => } - - ) : ( - - )} -
    + + {() => ( + <> +
    +
    +
    -
    {this.renderEscalation()}
    + {!searchResult || searchResult.length ? ( +
    +
    + + + +
    + {searchResult ? ( + + {(item) => } + + ) : ( + + )} +
    +
    +
    {this.renderEscalation()}
    +
    + ) : ( + + No escalations found, check your filtering and current team. + + + + + } + /> + )}
    - ) : ( - - No escalations found, check your filtering and current team. - - - - - } - /> - )} -
    - {showCreateEscalationChainModal && ( - { - this.setState({ - showCreateEscalationChainModal: false, - escalationChainIdToCopy: undefined, - }); - }} - onUpdate={this.handleEscalationChainCreate} - /> + {showCreateEscalationChainModal && ( + { + this.setState({ + showCreateEscalationChainModal: false, + escalationChainIdToCopy: undefined, + }); + }} + onUpdate={this.handleEscalationChainCreate} + /> + )} + )} - + ); } diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index ca4eb213..634a9121 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -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 state: IncidentPageState = { timelineFilter: 'all', resolutionNoteText: '', - wrongTeamError: false, - wrongTeamNoPermissions: false, + errorData: initErrorDataState(), }; componentDidMount() { @@ -93,28 +92,16 @@ class IncidentPage extends React.Component } 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 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 ( -
    -
    - - 404 - Incident not found - - - - -
    -
    - ); - } - if (wrongTeamError) { - return ( - - ); - } - - if (!incident) { + if (!incident && !isNotFoundError && !isWrongTeamError) { return (
    @@ -178,48 +126,75 @@ class IncidentPage extends React.Component } return ( - <> -
    - {this.renderHeader()} -
    -
    - - - + + {() => + errorData.isNotFoundError ? ( +
    +
    + + 404 + Incident not found + + + + +
    -
    {this.renderTimeline()}
    -
    -
    - {showIntegrationSettings && ( - { - 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 && ( - { - this.setState({ - showAttachIncidentForm: false, - }); - }} - onUpdate={this.update} - /> - )} - + ) : ( + <> +
    + {this.renderHeader()} +
    +
    + + + +
    +
    {this.renderTimeline()}
    +
    +
    + {showIntegrationSettings && ( + { + 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 && ( + { + this.setState({ + showAttachIncidentForm: false, + }); + }} + onUpdate={this.update} + /> + )} + + ) + } + ); } @@ -297,7 +272,7 @@ class IncidentPage extends React.Component Copy Link - + diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 63fb1c4b..228b82fa 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -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'; diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index fa331dc5..799671fd 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -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 state: IntegrationsState = { integrationsFilters: { searchTerm: '' }, showCreateIntegrationModal: false, + errorData: initErrorDataState(), }; alertReceiveChanneltoPoll: { [key: string]: number } = {}; @@ -58,30 +64,38 @@ class Integrations extends React.Component 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 } 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 ( - <> -
    -
    - -
    - {searchResult?.length ? ( -
    -
    - - - -
    - - {(item) => ( - { - this.setState({ - alertReceiveChannelToShowSettings: item.id, - integrationSettingsTab: IntegrationSettingsTab.Heartbeat, - }); - }} - /> - )} - -
    + + {() => ( + <> +
    +
    +
    -
    - { - this.setState({ - alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, - integrationSettingsTab, - }); - }} - /*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler( + {searchResult?.length ? ( +
    +
    + + + +
    + + {(item) => ( + { + this.setState({ + alertReceiveChannelToShowSettings: item.id, + integrationSettingsTab: IntegrationSettingsTab.Heartbeat, + }); + }} + /> + )} + +
    +
    +
    + { + this.setState({ + alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel, + integrationSettingsTab, + }); + }} + /*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler( store.selectedAlertReceiveChannel )}*/ + /> +
    +
    + ) : searchResult ? ( + + No integrations found. Review your filter and team settings. + + + + + } /> -
    + ) : ( + + )}
    - ) : searchResult ? ( - - No integrations found. Review your filter and team settings. - - - - - } - /> - ) : ( - - )} -
    - {alertReceiveChannelToShowSettings && ( - { - alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); - }} - startTab={integrationSettingsTab} - id={alertReceiveChannelToShowSettings} - onHide={() => { - this.setState({ - alertReceiveChannelToShowSettings: undefined, - integrationSettingsTab: undefined, - }); - getLocationSrv().update({ partial: true, query: { tab: undefined } }); - }} - /> + {alertReceiveChannelToShowSettings && ( + { + alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings); + }} + startTab={integrationSettingsTab} + id={alertReceiveChannelToShowSettings} + onHide={() => { + this.setState({ + alertReceiveChannelToShowSettings: undefined, + integrationSettingsTab: undefined, + }); + getLocationSrv().update({ partial: true, query: { tab: undefined } }); + }} + /> + )} + {showCreateIntegrationModal && ( + { + this.setState({ showCreateIntegrationModal: false }); + }} + onCreate={this.handleCreateNewAlertReceiveChannel} + /> + )} + )} - {showCreateIntegrationModal && ( - { - this.setState({ showCreateIntegrationModal: false }); - }} - onCreate={this.handleCreateNewAlertReceiveChannel} - /> - )} - + ); } diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 6ced40da..51d80e98 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -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 { - state: OutgoingWebhooksState = {}; + state: OutgoingWebhooksState = { + errorData: initErrorDataState(), + }; async componentDidMount() { this.update().then(this.parseQueryParams); @@ -44,27 +49,42 @@ class OutgoingWebhooks extends React.Component { + 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 -
    - ( -
    - Outgoing Webhooks - - - - - -
    + + {() => ( + <> +
    + ( +
    + Outgoing Webhooks + + + + + +
    + )} + rowKey="id" + columns={columns} + data={webhooks} + /> +
    + {outgoingWebhookIdToEdit && ( + )} - rowKey="id" - columns={columns} - data={webhooks} - /> -
    - {outgoingWebhookIdToEdit && ( - + )} - + ); } diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 1e16c5ef..94e967eb 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -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); }; diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css index 2e5746bd..09d0aa98 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.module.css +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -5,7 +5,7 @@ margin-top: 24px; --rotations-border: var(--border-medium); - --rotations-background: var(--primary-background); + --rotations-background: var(--background-secondary); } .header { diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 2410ada8..8e05ea45 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -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 startMoment: getStartOfWeek(store.currentTimezone), schedulePeriodType: 'week', renderType: 'timeline', + shiftIdToShowRotationForm: undefined, + shiftIdToShowOverridesForm: undefined, }; } @@ -74,9 +88,16 @@ class SchedulePage extends React.Component 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 - + {schedule?.name} {/* {users && ( - + + Current timezone: + + )} - + {/**/} {/* @@ -162,9 +186,9 @@ class SchedulePage extends React.Component -
    + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} -
    +
    {/*
    {/*
    */}
    - + onCreate={this.handleCreateRotation} onUpdate={this.handleUpdateRotation} onDelete={this.handleDeleteRotation} + shiftIdToShowRotationForm={shiftIdToShowRotationForm} + onShowRotationForm={this.handleShowRotationForm} /> onCreate={this.handleCreateOverride} onUpdate={this.handleUpdateOverride} onDelete={this.handleDeleteOverride} + shiftIdToShowRotationForm={shiftIdToShowOverridesForm} + onShowRotationForm={this.handleShowOverridesForm} />
    @@ -219,6 +252,28 @@ class SchedulePage extends React.Component ); } + 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; diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 9b2e5e3a..3fb0d618 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -21,6 +21,11 @@ import moment from 'moment-timezone'; import instructionsImage from 'assets/img/events_instructions.png'; import Avatar from 'components/Avatar/Avatar'; 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 SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters'; import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types'; @@ -44,7 +49,7 @@ import styles from './Schedules.module.css'; const cx = cn.bind(styles); interface SchedulesPageProps extends WithStoreProps, AppRootProps {} -interface SchedulesPageState { +interface SchedulesPageState extends PageBaseState { scheduleIdToEdit?: Schedule['id']; scheduleIdToDelete?: Schedule['id']; scheduleIdToExport?: Schedule['id']; @@ -59,6 +64,7 @@ class SchedulesPage extends React.Component { + parseQueryParams = async () => { + this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse + const { store, query: { id }, } = this.props; - if (id) { - const schedules = store.scheduleStore.getSearchResult(); - const scheduleId = schedules && schedules.find((res) => res.id === id)?.id; - if (scheduleId || id === 'new') { - this.setState({ scheduleIdToEdit: id }); - } else { - openErrorNotification(`Schedule with id=${id} is not found. Please select schedule from the list.`); + if (!id) {return;} + + let scheduleId: string = undefined; + const isNewSchedule = id === 'new'; + + if (!isNewSchedule) { + // load schedule only for valid id + const schedule = await store.scheduleStore + .loadItem(id, true) + .catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } })); + if (!schedule) { + return; } + + scheduleId = schedule.id; + } + + if (scheduleId || isNewSchedule) { + this.setState({ scheduleIdToEdit: id }); + } else { + openErrorNotification(`Schedule with id=${id} is not found. Please select schedule from the list.`); } }; @@ -96,10 +117,10 @@ class SchedulesPage extends React.Component -
    -
    - - On-call Schedules - - Use this to distribute notifications among team members you specified in the "Notify Users from on-call - schedule" step in escalation chains. - - -
    - {!schedules || schedules.length ? ( - ( -
    - - - - Your timezone is {timezoneStr} UTC{offset} - - - - - - - -
    + + {() => ( + <> +
    +
    + + On-call Schedules + + Use this to distribute notifications among team members you specified in the "Notify Users from + on-call schedule" step in{' '} + escalation chains. + + +
    + + {!schedules || schedules.length ? ( + ( +
    + + + + Your timezone is {timezoneStr} UTC{offset} + + + + + + + +
    + )} + rowKey="id" + columns={columns} + data={schedules} + expandable={{ + expandedRowRender: this.renderEvents, + expandRowByClick: true, + onExpand: this.onRowExpand, + expandedRowKeys: expandedSchedulesKeys, + onExpandedRowsChange: this.handleExpandedRowsChange, + }} + /> + ) : ( + + You haven’t added a schedule yet. + + + + + } + /> )} - rowKey="id" - columns={columns} - data={schedules} - expandable={{ - expandedRowRender: this.renderEvents, - expandRowByClick: true, - onExpand: this.onRowExpand, - expandedRowKeys: expandedSchedulesKeys, - onExpandedRowsChange: this.handleExpandedRowsChange, - }} - /> - ) : ( - - You haven’t added a schedule yet. - - - - - } - /> - )} -
    - {scheduleIdToEdit && ( - { - this.setState({ scheduleIdToEdit: undefined }); - getLocationSrv().update({ partial: true, query: { id: undefined } }); - }} - /> +
    + + {scheduleIdToEdit && ( + { + this.setState({ scheduleIdToEdit: undefined }); + getLocationSrv().update({ partial: true, query: { id: undefined } }); + }} + /> + )} + + {scheduleIdToDelete && ( + { + this.setState({ scheduleIdToDelete: undefined }); + }} + /> + )} + + {scheduleIdToExport && ( + this.setState({ scheduleIdToExport: undefined })} + > + + + )} + )} - {scheduleIdToDelete && ( - { - this.setState({ scheduleIdToDelete: undefined }); - }} - /> - )} - {scheduleIdToExport && ( - this.setState({ scheduleIdToExport: undefined })} - > - - - )} - + ); } diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 417761a0..aa979db6 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -233,12 +233,19 @@ class SchedulesPage extends React.Component
    ); }; + getScheduleClickHandler = (scheduleId: Schedule['id']) => { + return () => { + getLocationSrv().update({ query: { page: 'schedule', id: scheduleId } }); + }; + }; + renderStatus = (item: Schedule) => { const { store: { scheduleStore }, diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index a8ab3c5b..3e75aacd 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -9,15 +9,18 @@ import { observer } from 'mobx-react'; import Avatar from 'components/Avatar/Avatar'; 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 UsersFilters from 'components/UsersFilters/UsersFilters'; import UserSettings from 'containers/UserSettings/UserSettings'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { CrossCircleIcon } from 'icons'; import { getRole } from 'models/user/user.helpers'; -import { User, User as UserType, UserRole } from 'models/user/user.types'; -import { AppFeature } from 'state/features'; +import { User as UserType, UserRole } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; @@ -32,8 +35,9 @@ interface UsersProps extends WithStoreProps, AppRootProps {} const ITEMS_PER_PAGE = 100; -interface UsersState { +interface UsersState extends PageBaseState { page: number; + isWrongTeam: boolean; userPkToEdit?: UserType['pk'] | 'new'; usersFilters?: { searchTerm: string; @@ -45,18 +49,20 @@ interface UsersState { class Users extends React.Component { state: UsersState = { page: 1, + isWrongTeam: false, userPkToEdit: undefined, usersFilters: { searchTerm: '', roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER], }, + + errorData: initErrorDataState(), }; initialUsersLoaded = false; async componentDidMount() { const { - store, query: { p }, } = this.props; this.setState({ page: p ? Number(p) : 1 }, this.updateUsers); @@ -91,13 +97,17 @@ class Users extends React.Component { } parseParams = async () => { + this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse + const { store, query: { id }, } = this.props; if (id) { - await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id))); + await (id === 'me' ? store.userStore.loadCurrentUser() : store.userStore.loadUser(String(id), true)).catch( + (error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }) + ); const userPkToEdit = String(id === 'me' ? store.userStore.currentUserPk : id); @@ -108,8 +118,8 @@ class Users extends React.Component { }; render() { - const { usersFilters, userPkToEdit, page } = this.state; - const { store } = this.props; + const { usersFilters, userPkToEdit, page, errorData } = this.state; + const { store, query } = this.props; const { userStore } = store; const columns = [ @@ -131,11 +141,6 @@ class Users extends React.Component { key: 'note', render: this.renderNote, }, - // { - // width: '15%', - // key: 'contacts', - // render: this.renderContacts, - // }, { width: '20%', title: 'Default Notifications', @@ -154,6 +159,7 @@ class Users extends React.Component { render: this.renderButtons, }, ]; + const handleClear = () => this.setState( { usersFilters: { searchTerm: '', roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER] } }, @@ -165,64 +171,81 @@ class Users extends React.Component { const { count, results } = userStore.getSearchResult(); return ( -
    -
    - - {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( - <> -
    - - -
    + + {() => ( + <> +
    +
    +
    +
    +
    + Users + + To manage permissions or add users, please visit{' '} + Grafana user management + +
    +
    + + + +
    + {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( + <> +
    + + +
    - - - ) : ( - - You don't have enough permissions to view other users because you are not Admin.{' '} - Click here to open your profile - - } - severity="info" - /> - )} -
    - {userPkToEdit && } -
    + + + ) : ( + + You don't have enough permissions to view other users because you are not Admin.{' '} + Click here to open your profile + + } + severity="info" + /> + )} +
    + {userPkToEdit && } +
    + + )} + ); } diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index e68a701b..605ba9f8 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -1,6 +1,7 @@ import { AppPluginMeta } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { action, observable } from 'mobx'; +import moment from 'moment-timezone'; import qs from 'query-string'; import { OnCallAppSettings } from 'types'; @@ -40,7 +41,7 @@ export class RootBaseStore { appLoading = true; @observable - currentTimezone: Timezone = 'UTC'; + currentTimezone: Timezone = moment.tz.guess() as Timezone; @observable backendVersion = ''; @@ -89,6 +90,9 @@ export class RootBaseStore { @observable incidentsPage: any = this.initialQuery.p ? Number(this.initialQuery.p) : 1; + @observable + onCallApiUrl: string; + // -------------------------- userStore: UserStore = new UserStore(this); @@ -186,6 +190,8 @@ export class RootBaseStore { return; } + this.onCallApiUrl = meta.jsonData.onCallApiUrl; + let syncStartStatus = await this.startSync(); if (syncStartStatus.is_user_anonymous) { this.isUserAnonymous = true; @@ -219,7 +225,7 @@ export class RootBaseStore { this.handleSyncException(e); }); - if (counter >= 5) { + if (counter >= 10) { clearInterval(interval); this.retrySync = true; } diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index b640158c..bf3e3852 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -10,6 +10,10 @@ --gray-9: #434343; --cyan-1: #e6fffb; --purple-9: #22075e; + --border-radius: 2px; + --gradient-brandHorizontal: linear-gradient(90deg, #f83 0%, #f53e4c 100%); + --gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%); + --always-gray: #ccccdc; } .theme-light { @@ -29,6 +33,15 @@ --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); --oncall-icon-stroke-color: #fff; + --background-canvas: #f4f5f5; + --background-primary: #fff; + --background-secondary: #f4f5f5; + --border-medium: 1px solid rgba(36, 41, 46, 0.3); + --border-strong: 1px solid rgba(36, 41, 46, 0.4); + --border-weak: 1px solid rgba(36, 41, 46, 0.12); + --shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.2); + --shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.2); + --shadows-z3: 0 13px 20px 1px rgba(24, 26, 27, 0.18); } .theme-dark { @@ -48,9 +61,17 @@ --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1); --focused-box-shadow: rgb(17 18 23) 0 0 0 2px, rgb(61 113 217) 0 0 0 4px; - --border-medium: 1px solid rgba(204, 204, 220, 0.15); --hover-selected: rgba(204, 204, 220, 0.12); --hover-selected-hardcoded: #34363d; --secondary-background-shade: rgba(204, 204, 220, 0.2); --oncall-icon-stroke-color: #181b1f; + --background-canvas: #111217; + --background-primary: #181b1f; + --background-secondary: #22252b; + --border-medium: 1px solid rgba(204, 204, 220, 0.15); + --border-strong: 1px solid rgba(204, 204, 220, 0.25); + --border-weak: 1px solid rgba(204, 204, 220, 0.07); + --shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.75); + --shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.75); + --shadows-z3: 0 8px 24px rgb(1, 4, 9); } diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index a4440abf..3583dfd8 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1381,15 +1381,6 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~2.2.5" -"@es-joy/jsdoccomment@~0.31.0": - version "0.31.0" - resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz#dbc342cc38eb6878c12727985e693eaef34302bc" - integrity sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ== - dependencies: - comment-parser "1.3.1" - esquery "^1.4.0" - jsdoc-type-pratt-parser "~3.1.0" - "@eslint/eslintrc@^1.2.1", "@eslint/eslintrc@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" @@ -1405,21 +1396,6 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/eslintrc@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" - integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.4.0" - globals "^13.15.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - "@formatjs/ecma402-abstract@1.11.10": version "1.11.10" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.10.tgz#1b61909ce069d1fa62bafb163aaff59d524c094d" @@ -1738,15 +1714,6 @@ uplot "1.6.22" uuid "8.3.2" -"@humanwhocodes/config-array@^0.10.4": - version "0.10.4" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" - integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== - dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" - minimatch "^3.0.4" - "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -1756,16 +1723,6 @@ debug "^4.1.1" minimatch "^3.0.4" -"@humanwhocodes/gitignore-to-minimatch@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" - integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3210,21 +3167,6 @@ semver "^7.3.5" tsutils "^3.21.0" -"@typescript-eslint/eslint-plugin@^5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" - integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== - dependencies: - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/type-utils" "5.36.2" - "@typescript-eslint/utils" "5.36.2" - debug "^4.3.4" - functional-red-black-tree "^1.0.1" - ignore "^5.2.0" - regexpp "^3.2.0" - semver "^7.3.7" - tsutils "^3.21.0" - "@typescript-eslint/parser@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.16.0.tgz#e4de1bde4b4dad5b6124d3da227347616ed55508" @@ -3243,14 +3185,6 @@ "@typescript-eslint/types" "5.16.0" "@typescript-eslint/visitor-keys" "5.16.0" -"@typescript-eslint/scope-manager@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" - integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - "@typescript-eslint/type-utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz#b482bdde1d7d7c0c7080f7f2f67ea9580b9e0692" @@ -3260,26 +3194,11 @@ debug "^4.3.2" tsutils "^3.21.0" -"@typescript-eslint/type-utils@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" - integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== - dependencies: - "@typescript-eslint/typescript-estree" "5.36.2" - "@typescript-eslint/utils" "5.36.2" - debug "^4.3.4" - tsutils "^3.21.0" - "@typescript-eslint/types@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee" integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g== -"@typescript-eslint/types@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" - integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== - "@typescript-eslint/typescript-estree@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz#32259459ec62f5feddca66adc695342f30101f61" @@ -3293,19 +3212,6 @@ semver "^7.3.5" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" - integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - "@typescript-eslint/utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.16.0.tgz#42218b459d6d66418a4eb199a382bdc261650679" @@ -3318,18 +3224,6 @@ eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/utils@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" - integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== - dependencies: - "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/typescript-estree" "5.36.2" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - "@typescript-eslint/visitor-keys@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz#f27dc3b943e6317264c7492e390c6844cd4efbbb" @@ -3338,14 +3232,6 @@ "@typescript-eslint/types" "5.16.0" eslint-visitor-keys "^3.0.0" -"@typescript-eslint/visitor-keys@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" - integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== - dependencies: - "@typescript-eslint/types" "5.36.2" - eslint-visitor-keys "^3.3.0" - "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3768,7 +3654,7 @@ array.prototype.flat@^1.2.5: define-properties "^1.1.3" es-abstract "^1.19.0" -array.prototype.flatmap@^1.2.5, array.prototype.flatmap@^1.3.0: +array.prototype.flatmap@^1.2.5: version "1.3.0" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz#a7e8ed4225f4788a70cd910abcf0791e76a5534f" integrity sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg== @@ -5733,25 +5619,12 @@ eslint-plugin-jsdoc@38.0.6: semver "^7.3.5" spdx-expression-parse "^3.0.1" -eslint-plugin-jsdoc@^39.3.6: - version "39.3.6" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz#6ba29f32368d72a51335a3dc9ccd22ad0437665d" - integrity sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g== - dependencies: - "@es-joy/jsdoccomment" "~0.31.0" - comment-parser "1.3.1" - debug "^4.3.4" - escape-string-regexp "^4.0.0" - esquery "^1.4.0" - semver "^7.3.7" - spdx-expression-parse "^3.0.1" - eslint-plugin-react-hooks@4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz#318dbf312e06fab1c835a4abef00121751ac1172" integrity sha512-XslZy0LnMn+84NEG9jSGR6eGqaZB3133L8xewQo3fQagbQuGt7a63gf+P1NGKZavEYEC3UXaWEAA/AqDkuN6xA== -eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.6.0: +eslint-plugin-react-hooks@4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== @@ -5776,26 +5649,6 @@ eslint-plugin-react@7.29.4: semver "^6.3.0" string.prototype.matchall "^4.0.6" -eslint-plugin-react@^7.31.7: - version "7.31.7" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.31.7.tgz#36fb1c611a7db5f757fce09cbbcc01682f8b0fbb" - integrity sha512-8NldBTeYp/kQoTV1uT0XF6HcmDqbgZ0lNPkN0wlRw8DJKXEnaWu+oh/6gt3xIhzvQ35wB2Y545fJhIbJSZ2NNw== - dependencies: - array-includes "^3.1.5" - array.prototype.flatmap "^1.3.0" - doctrine "^2.1.0" - estraverse "^5.3.0" - jsx-ast-utils "^2.4.1 || ^3.0.0" - minimatch "^3.1.2" - object.entries "^1.1.5" - object.fromentries "^2.0.5" - object.hasown "^1.1.1" - object.values "^1.1.5" - prop-types "^15.8.1" - resolve "^2.0.0-next.3" - semver "^6.3.0" - string.prototype.matchall "^4.0.7" - eslint-plugin-rulesdir@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.1.tgz" @@ -5916,51 +5769,6 @@ eslint@8.20.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -eslint@^8.23.0: - version "8.23.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" - integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== - dependencies: - "@eslint/eslintrc" "^1.3.1" - "@humanwhocodes/config-array" "^0.10.4" - "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" - "@humanwhocodes/module-importer" "^1.0.1" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.1.1" - eslint-utils "^3.0.0" - eslint-visitor-keys "^3.3.0" - espree "^9.4.0" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - functional-red-black-tree "^1.0.1" - glob-parent "^6.0.1" - globals "^13.15.0" - globby "^11.1.0" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.1" - regexpp "^3.2.0" - strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" - text-table "^0.2.0" - espree@^9.3.1, espree@^9.3.2: version "9.3.3" resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" @@ -5970,15 +5778,6 @@ espree@^9.3.1, espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" -espree@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" - integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== - dependencies: - acorn "^8.8.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.3.0" - esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -6660,7 +6459,7 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: +globby@^11.0.3, globby@^11.0.4: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -6700,11 +6499,6 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -grapheme-splitter@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" - integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== - gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -8084,11 +7878,6 @@ jsdoc-type-pratt-parser@~2.2.5: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-2.2.5.tgz#c9f93afac7ee4b5ed4432fe3f09f7d36b05ed0ff" integrity sha512-2a6eRxSxp1BW040hFvaJxhsCMI9lT8QB8t14t+NY5tC5rckIR0U9cr2tjOeaFirmEOy6MHvmJnY7zTBHq431Lw== -jsdoc-type-pratt-parser@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e" - integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw== - jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -9119,7 +8908,7 @@ object.fromentries@^2.0.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.hasown@^1.1.0, object.hasown@^1.1.1: +object.hasown@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.1.tgz#ad1eecc60d03f49460600430d97f23882cf592a3" integrity sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A== @@ -11819,7 +11608,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2 is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string.prototype.matchall@^4.0.6, string.prototype.matchall@^4.0.7: +string.prototype.matchall@^4.0.6: version "4.0.7" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d" integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==