Merge pull request #588 from grafana/dev

Merge dev to main
This commit is contained in:
Michael Derynck 2022-09-30 12:46:12 -06:00 committed by GitHub
commit 46667776ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1493 additions and 1147 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ venv
.python-version
.env
.env_hobby
.env.dev
.vscode
dump.rdb
.idea

View file

@ -1,6 +1,13 @@
# Change Log
## v1.0.37 (2022-09-23)
## 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

View file

@ -56,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`.
@ -83,7 +83,7 @@ python manage.py createsuperuser
# 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)
@ -248,7 +248,7 @@ 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
@ -265,10 +265,10 @@ pytest -n4
### 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
@ -279,5 +279,5 @@ pytest -n4
- 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

View file

@ -21,50 +21,55 @@ Developer-friendly incident response with brilliant Slack integration.
We prepared multiple environments: [production](https://grafana.com/docs/grafana-cloud/oncall/open-source/#production-environment), [developer](DEVELOPER.md) and hobby:
1. Download docker-compose.yaml:
```bash
curl -fsSL https://raw.githubusercontent.com/grafana/oncall/dev/docker-compose.yml -o docker-compose.yml
```
2. Set variables:
```bash
echo "DOMAIN=http://localhost:8080
COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana
SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long
RABBITMQ_PASSWORD=rabbitmq_secret_pw
MYSQL_PASSWORD=mysql_secret_pw
COMPOSE_PROFILES=with_grafana # Remove this line if you want to use existing grafana
GRAFANA_USER=admin
GRAFANA_PASSWORD=admin" > .env_hobby
MYSQL_PASSWORD=mysql_secret_pw" > .env
```
3. Launch services:
```bash
docker-compose --env-file .env_hobby -f docker-compose.yml up -d
docker-compose up -d
```
4. Issue one-time invite token:
```bash
docker-compose --env-file .env_hobby -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override
docker-compose run engine python manage.py issue_invite_for_the_frontend --override
```
**Note**: if you remove the plugin configuration and reconfigure it, you will need to generate a new one-time invite token for your new configuration.
5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_:
```
Invite token: ^^^ from the previous step.
OnCall backend URL: http://engine:8080
Grafana Url: http://grafana:3000
```
6. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud.
6. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud.
## Update version
To update your Grafana OnCall hobby environment:
```shell
# Update Docker images
docker-compose --env-file .env_hobby -f docker-compose.yml pull engine celery oncall_db_migration
# Update Docker image
docker-compose pull engine
# Re-deploy
docker-compose --env-file .env_hobby -f docker-compose.yml up -d --remove-orphans
docker-compose up -d
```
After updating the engine, you'll also need to click the "Update" button on the [plugin version page](http://localhost:3000/plugins/grafana-oncall-app?page=version-history).
@ -76,14 +81,13 @@ See [Grafana docs](https://grafana.com/docs/grafana/latest/administration/plugin
<a href="https://github.com/grafana/oncall/discussions"><img width="200px" src="docs/img/GH_discussions.png"></a>
<a href="https://slack.grafana.com/"><img width="200px" src="docs/img/slack.png"></a>
## Stargazers over time
[![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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -35,7 +35,8 @@ The above command returns JSON structured in the following way:
"acknowledged_at": null,
"title": "Memory above 90% threshold",
"permalinks": {
"slack": null
"slack": "https://ghostbusters.slack.com/archives/C1H9RESGA/p135854651500008",
"telegram": "https://t.me/c/5354/1234?thread=1234"
}
}
]

View file

@ -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,

View file

@ -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

View file

@ -401,15 +401,34 @@ 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:
# TODO: refactor 'permalink' property (maybe 'slack_permalink'?) once we add the next permalink
return {
"slack": self.permalink,
"slack": self.slack_permalink,
"telegram": self.telegram_permalink,
}
@property

View file

@ -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

View file

@ -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

View file

@ -132,7 +132,7 @@ class AlertGroupSerializer(AlertGroupListSerializer):
fields = AlertGroupListSerializer.Meta.fields + [
"alerts",
"render_after_resolve_report_json",
"permalink",
"slack_permalink",
"last_alert_at",
]

View file

@ -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

View file

@ -1401,3 +1401,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

View file

@ -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))

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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
@ -15,7 +17,14 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
state = serializers.SerializerMethodField()
SELECT_RELATED = ["channel", "channel_filter", "slack_message"]
PREFETCH_RELATED = ["alerts"]
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

View file

@ -41,6 +41,7 @@ def construct_expected_response_from_incidents(incidents):
"title": None,
"permalinks": {
"slack": None,
"telegram": None,
},
}
)

View file

@ -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}"

View file

@ -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()

View file

@ -9,7 +9,7 @@ is_able_to_autoresolve = True
is_demo_alert_enabled = True
description = """
Alerts from Grafana Alertmanager are automatically routed to this integration."
Alerts from Grafana Alertmanager are automatically routed to this integration.
{% for dict_item in grafana_alerting_entities %}
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
to open contact point, and

View file

@ -12,7 +12,7 @@ is_able_to_autoresolve = True
is_demo_alert_enabled = True
description = """ \
Alerts from Grafana Alertmanager are automatically routed to this integration."
Alerts from Grafana Alertmanager are automatically routed to this integration.
{% for dict_item in grafana_alerting_entities %}
<br>Click <a href='{{dict_item.contact_point_url}}' target='_blank'>here</a>
to open contact point, and

View file

@ -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": {

View file

@ -1,67 +1,106 @@
# Change Log
## 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 +108,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 +127,22 @@
- Improve alert group list load speeds and simplify caching system
## v1.0.6 (2022-07-12)
- Manual Incidents enabled for teams
- Fix phone notifications for OSS
- Public API improvements
## v1.0.5 (2022-07-06)
- Bump Django to 3.2.14
- Fix PagerDuty iCal parsing
## 1.0.4 (2022-06-28)
- Allow Telegram DMs without channel connection.
## 1.0.3 (2022-06-27)
- Fix users public api endpoint. Now it returns users with all roles.
- Fix redundant notifications about gaps in schedules.
- Frontend fixes.

View file

@ -154,7 +154,7 @@ export const Root = observer((props: AppRootProps) => {
return (
<DefaultPageLayout {...props}>
<GrafanaTeamSelect />
<GrafanaTeamSelect currentPage={page} />
<Page {...props} path={pathWithoutLeadingSlash} />
</DefaultPageLayout>
);

View file

@ -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;
}

View file

@ -0,0 +1,22 @@
import { PageErrorData } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
export function initErrorDataState(): Partial<PageErrorData> {
return { isWrongTeamError: false, wrongTeamNoPermissions: false };
}
export function getWrongTeamResponseInfo({ response }): Partial<PageErrorData> {
if (response) {
if (response.status === 404) {
return { isNotFoundError: true };
} else if (response.status === 403 && response.data.error_code === 'wrong_team') {
let res = response.data;
if (res.owner_team) {
return { isWrongTeamError: true, switchToTeam: { name: res.owner_team.name, id: res.owner_team.id } };
} else {
return { isWrongTeamError: true, wrongTeamNoPermissions: true };
}
}
}
return { isNotFoundError: true };
}

View file

@ -1,30 +1,59 @@
import React, { FC } from 'react';
import React, { useEffect } from 'react';
import { Button, VerticalGroup, Icon } from '@grafana/ui';
import { Button, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { PropTypes } from 'mobx-react';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import { ChangeTeamIcon } from 'icons';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { useStore } from 'state/useStore';
import { openWarningNotification } from 'utils';
import styles from './WrongTeamStub.module.css';
import styles from './PageErrorHandlingWrapper.module.css';
const cx = cn.bind(styles);
export interface WrongTeamStubProps {
className?: string;
objectName: string;
pageName: string;
currentTeam?: string;
switchToTeam?: { name: string; id: string };
wrongTeamNoPermissions?: boolean;
export interface PageBaseState {
errorData: PageErrorData;
}
const WrongTeamStub: FC<WrongTeamStubProps> = (props) => {
export interface PageErrorData {
isNotFoundError?: boolean;
isWrongTeamError?: boolean;
wrongTeamNoPermissions?: boolean;
switchToTeam?: { name: string; id: string };
}
export default function PageErrorHandlingWrapper({
errorData,
objectName,
pageName,
itemNotFoundMessage,
children,
}: {
errorData: PageErrorData;
objectName: string;
pageName: string;
itemNotFoundMessage?: string;
children: () => JSX.Element;
}) {
useEffect(() => {
const { isWrongTeamError, isNotFoundError } = errorData;
if (!isWrongTeamError && isNotFoundError && itemNotFoundMessage) {
openWarningNotification(itemNotFoundMessage);
}
}, [errorData.isNotFoundError]);
const store = useStore();
const { objectName, pageName, currentTeam, switchToTeam, className, wrongTeamNoPermissions } = props;
if (!errorData.isWrongTeamError) {return children();}
const currentTeamId = store.userStore.currentUser?.current_team;
const currentTeam = store.grafanaTeamStore.items[currentTeamId]?.name;
const { switchToTeam, wrongTeamNoPermissions } = errorData;
const onTeamChange = async (teamId: GrafanaTeam['id']) => {
await store.userStore.updateCurrentUser({ current_team: teamId });
@ -57,12 +86,10 @@ const WrongTeamStub: FC<WrongTeamStubProps> = (props) => {
Change the team
</Button>
)}
<Text type="secondary" className={cx('return-to-list')}>
<Text type="secondary">
Or return to the <PluginLink query={{ page: pageName }}>{objectName} list</PluginLink> for team {currentTeam}
</Text>
</VerticalGroup>
</div>
);
};
export default WrongTeamStub;
}

View file

@ -11,7 +11,7 @@ interface PluginLinkProps extends LocationUpdate {
disabled?: boolean;
className?: string;
wrap?: boolean;
children: any
children: any;
}
const cx = cn.bind(styles);

View file

@ -14,14 +14,11 @@ import GSelect from 'containers/GSelect/GSelect';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import UserTooltip from 'containers/UserTooltip/UserTooltip';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { ActionDTO } from 'models/action';
import { prepareEscalationPolicy } from 'models/escalation_policy/escalation_policy.helpers';
import {
EscalationPolicy as EscalationPolicyType,
EscalationPolicyOption,
} from 'models/escalation_policy/escalation_policy.types';
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
import { User, UserRole } from 'models/user/user.types';
import { WaitDelay } from 'models/wait_delay';
import { SelectOption } from 'state/types';
import { UserAction } from 'state/userAction';
@ -272,15 +269,15 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
return (
<WithPermissionControl key="notify_schedule" disableByPaywall userAction={UserAction.UpdateEscalationPolicies}>
<RemoteSelect
showSearch={false}
<GSelect
modelName="scheduleStore"
displayField="name"
valueField="id"
placeholder="Select Schedule"
className={cx('select', 'control')}
value={notify_schedule}
valueField="id"
onChange={this._getOnChangeHandler('notify_schedule')}
href={'/schedules/?short=true'}
fieldToShow="name"
placeholder="Select Schedule"
fromOrganization
/>
</WithPermissionControl>
);
@ -319,6 +316,7 @@ export class EscalationPolicy extends React.Component<EscalationPolicyProps, any
className={cx('select', 'control')}
value={custom_button_trigger}
onChange={this._getOnChangeHandler('custom_button_trigger')}
fromOrganization
/>
</WithPermissionControl>
);

View file

@ -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;

View file

@ -51,7 +51,7 @@ const GTable: FC<Props> = (props) => {
<VerticalGroup justify="flex-end">
<Table
rowKey={rowKey}
className={cx('root', className)}
className={cx('root', 'filter-table', className)}
columns={columns}
data={data}
expandable={expandable}

View file

@ -51,6 +51,7 @@ const Text: TextInterface = (props) => {
clearBeforeEdit = false,
hidden = false,
editModalTitle = 'New value',
style,
} = props;
const [isEditMode, setIsEditMode] = useState<boolean>(false);
@ -87,6 +88,7 @@ const Text: TextInterface = (props) => {
'no-wrap': !wrap,
keyboard,
})}
style={style}
>
{hidden ? PLACEHOLDER : children}
{editable && (
@ -148,12 +150,12 @@ interface TitleProps extends TextProps {
}
const Title: FC<TitleProps> = (props) => {
const { level, className, ...restProps } = props;
const { level, className, style, ...restProps } = props;
// @ts-ignore
const Tag: keyof JSX.IntrinsicElements = `h${level}`;
return (
<Tag className={cx('title', className)}>
<Tag className={cx('title', className)} style={style}>
<Text {...restProps} />
</Tag>
);

View file

@ -8,7 +8,6 @@
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: rgba(204, 204, 220, 0.65);
pointer-events: none;
}

View file

@ -3,6 +3,8 @@ import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Text from 'components/Text/Text';
import styles from './TimelineMarks.module.css';
interface TimelineMarksProps {
@ -60,7 +62,9 @@ const TimelineMarks: FC<TimelineMarksProps> = (props) => {
{momentsToRender.map((m, i) => {
return (
<div key={i} className={cx('weekday')}>
<div className={cx('weekday-title')}>{m.moment.format('ddd D MMM')}</div>
<div className={cx('weekday-title')}>
<Text type="secondary">{m.moment.format('ddd D MMM')}</Text>
</div>
<div className={cx('weekday-times')}>
{m.moments.map((mm, j) => (
<div key={j} className={cx('weekday-time')}>
@ -69,7 +73,7 @@ const TimelineMarks: FC<TimelineMarksProps> = (props) => {
'weekday-time-title__hidden': i === 0 && j === 0,
})}
>
{mm.format('HH:mm')}
<Text type="secondary">{mm.format('HH:mm')}</Text>
</div>
</div>
))}

View file

@ -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 {

View file

@ -5,6 +5,7 @@ import { arrayMoveImmutable } from 'array-move';
import cn from 'classnames/bind';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import Text from 'components/Text/Text';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import { User } from 'models/user/user.types';
@ -23,7 +24,7 @@ interface UserGroupsProps {
const cx = cn.bind(styles);
const DragHandle = () => <IconButton name="draggabledots" />;
const DragHandle = () => <IconButton className={cx('icon')} name="draggabledots" />;
const SortableHandleHoc = SortableHandle(DragHandle);
@ -94,7 +95,7 @@ const UserGroups = (props: UserGroupsProps) => {
{renderUser(item.data)}
<div className={cx('user-buttons')}>
<HorizontalGroup>
<IconButton className={cx('delete-icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<IconButton className={cx('icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<SortableHandleHoc />
</HorizontalGroup>
</div>
@ -155,14 +156,16 @@ const SortableList = SortableContainer<SortableListProps>(({ items, handleAddGro
</SortableItem>
) : isMultipleGroups ? (
<SortableItem key={item.key} index={index}>
<li className={cx('separator')}>{item.data.name}</li>
<li className={cx('separator')}>
<Text type="secondary">{item.data.name}</Text>
</li>
</SortableItem>
) : null
)}
{isMultipleGroups && items[items.length - 1]?.type === 'item' && (
<SortableItem disabled key="New Group" index={items.length + 1}>
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
Add user group +
<Text type="secondary">Add user group +</Text>
</li>
</SortableItem>
)}

View file

@ -90,7 +90,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
return (
<div className={cx('root')}>
<Select value={value} onChange={handleChange} width={100} placeholder="UTC Timezone" options={options} />
<Select value={value} onChange={handleChange} width={100} placeholder={propValue} options={options} />
</div>
);
};

View file

@ -804,8 +804,8 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
alertReceiveChannelStore.updateCounters();
openNotification(
<div>
Demo alert was generated. Find it in the
<PluginLink query={{ page: 'incidents' }}> "Incidents" </PluginLink>
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'incidents' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
</div>
);
@ -821,8 +821,8 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
alertReceiveChannelStore.sendDemoAlertToParticularRoute(id).then(() => {
openNotification(
<div>
Demo alert was generated. Find it in the
<PluginLink query={{ page: 'incidents' }}> "Incidents" </PluginLink>
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'incidents' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
</div>
);

View file

@ -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];

View file

@ -32,6 +32,7 @@ interface GSelectProps {
showWarningIfEmptyValue?: boolean;
showError?: boolean;
nullItemName?: string;
fromOrganization?: boolean;
filterOptions?: (id: any) => boolean;
dropdownRender?: (menu: ReactElement) => ReactElement;
getOptionLabel?: <T>(item: SelectableValue<T>) => React.ReactNode;
@ -59,6 +60,7 @@ const GSelect = observer((props: GSelectProps) => {
showWarningIfEmptyValue = false,
getDescription,
filterOptions,
fromOrganization,
} = props;
const store = useStore();
@ -123,7 +125,7 @@ const GSelect = observer((props: GSelectProps) => {
(values as string[]).forEach((value: string) => {
if (!isNil(value) && !model.items[value] && model.updateItem) {
model.updateItem(value);
model.updateItem(value, fromOrganization);
}
});
}, [value]);

View file

@ -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]

View file

@ -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: {

View file

@ -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';

View file

@ -11,12 +11,8 @@ import {
Label,
Legend,
LoadingPlaceholder,
Icon,
Alert,
Modal,
} from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import { OnCallAppSettings } from 'types';
import Block from 'components/GBlock/Block';
@ -28,6 +24,8 @@ import { createGrafanaToken, getPluginSyncStatus, startPluginSync, updateGrafana
import { GRAFANA_LICENSE_OSS } from 'utils/consts';
import { getItem, setItem } from 'utils/localStorage';
import { constructSyncErrorMessage, constructErrorActionMessage } from './helpers';
import styles from './PluginConfigPage.module.css';
const cx = cn.bind(styles);
@ -45,6 +43,8 @@ export const PluginConfigPage = (props: Props) => {
const [isSelfHostedInstall, setIsSelfHostedInstall] = useState<boolean>(true);
const [retrySync, setRetrySync] = useState<boolean>(false);
const INVALID_INVITE_TOKEN_ERROR_MSG = `It seems like your invite token may be invalid. ${constructErrorActionMessage('generating a new invite token')}`;
const setupPlugin = useCallback(async () => {
setItem('onCallApiUrl', onCallApiUrl);
setItem('grafanaUrl', grafanaUrl);
@ -129,25 +129,37 @@ export const PluginConfigPage = (props: Props) => {
}, []);
const handleSyncException = useCallback((e) => {
const buildErrMsg = (msg: string): string =>
constructSyncErrorMessage(msg, plugin.meta.jsonData.onCallApiUrl);
if (plugin.meta.jsonData?.onCallApiUrl) {
let statusMessage = plugin.meta.jsonData.onCallApiUrl + '\n' + e + ', retry or check settings & re-initialize.';
if (e.response.status == 404) {
statusMessage += '\nIf Grafana OnCall was just installed, restart Grafana for OnCall routes to be available.';
const { status: statusCode } = e.response;
let statusMessage: string;
if (statusCode == 403) {
statusMessage = buildErrMsg(INVALID_INVITE_TOKEN_ERROR_MSG);
} else if (statusCode === 404) {
statusMessage = buildErrMsg('If Grafana OnCall was just installed, restart Grafana for OnCall routes to be available.');
} else if (statusCode === 502) {
statusMessage = buildErrMsg(`Unable to communicate with either the Grafana API, or Grafana OnCall engine API. ${constructErrorActionMessage('verify that the API URLs that you entered are correct')}`);
} else {
statusMessage = buildErrMsg(`An unknown error occured. ${constructErrorActionMessage()}. If the error still occurs please reach out to support.`)
}
setPluginStatusMessage(statusMessage);
setRetrySync(true);
} else {
setPluginStatusMessage('OnCall has not been setup, configure & initialize below.');
setPluginStatusMessage(buildErrMsg('OnCall has not been setup, configure & initialize below.'));
}
setPluginStatusOk(false);
setPluginConfigLoading(false);
}, []);
const finishSync = useCallback((get_sync_response) => {
if (get_sync_response.token_ok) {
const finishSync = useCallback((getSyncResponse) => {
if (getSyncResponse.token_ok) {
const versionInfo =
get_sync_response.version && get_sync_response.license
? ` (${get_sync_response.license}, ${get_sync_response.version})`
getSyncResponse.version && getSyncResponse.license
? ` (${getSyncResponse.license}, ${getSyncResponse.version})`
: '';
let pluginStatusMessage = `Connected to OnCall${versionInfo}\n - OnCall URL: ${plugin.meta.jsonData.onCallApiUrl}\n`
@ -159,9 +171,8 @@ export const PluginConfigPage = (props: Props) => {
setIsSelfHostedInstall(plugin.meta.jsonData?.license === GRAFANA_LICENSE_OSS);
setPluginStatusOk(true);
} else {
setPluginStatusMessage(
`OnCall failed to connect to this grafana via: ${plugin.meta.jsonData.grafanaUrl} check URL, network, and API key.`
);
setPluginStatusMessage(constructSyncErrorMessage(INVALID_INVITE_TOKEN_ERROR_MSG,
plugin.meta.jsonData.grafanaUrl));
setRetrySync(true);
}
setPluginConfigLoading(false);
@ -221,14 +232,10 @@ export const PluginConfigPage = (props: Props) => {
)}
<p>{'Plugin <-> backend connection status'}</p>
<pre>
<Text type="link">{pluginStatusMessage}</Text>
<Text>{pluginStatusMessage}</Text>
</pre>
<HorizontalGroup>
{/* <p>{'Plugin <-> backend connection status'}</p>
<pre>
<Text type="link">{pluginStatusMessage}</Text>
</pre> */}
{retrySync && (
<Button variant="primary" onClick={startSync} size="md">
Retry

View file

@ -0,0 +1,5 @@
export const constructSyncErrorMessage = (errMsg: string, url?: string): string =>
`${url ? `${url}\n` : ''}${errMsg}`;
export const constructErrorActionMessage = (msg?: string): string =>
`Try removing your current configuration, ${msg ? msg : 'double checking your settings'}, and re-initializing the plugin.\nBy removing your current configuration, you will need to ensure that you regenerate a new invite token, and input this in your new configuration.`

View file

@ -54,7 +54,7 @@
position: absolute;
left: 450px;
width: 1px;
background: #fff;
background: var(--gradient-brandVertical);
top: -10px;
bottom: -10px;
z-index: 1;

View file

@ -133,7 +133,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
return (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
</div>
<WorkingHours
timezone={timezone}

View file

@ -92,7 +92,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
return (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
</div>
<WorkingHours
timezone={timezone}

View file

@ -1,13 +1,15 @@
.root {
border: var(--rotations-border);
border-radius: 2px;
background: var(--rotations-background);
border: var(--rotations-border);
display: flex;
flex-direction: column;
border-radius: var(--border-radius);
}
.current-time {
position: absolute;
width: 1px;
background: #fff;
background: var(--gradient-brandVertical);
top: 0;
bottom: 0;
z-index: 1;
@ -19,10 +21,6 @@
}
.title {
font-weight: 500;
font-size: 19px;
line-height: 24px;
color: rgba(204, 204, 220, 0.65);
margin: 16px 0;
}
@ -42,11 +40,7 @@
.layer-title {
text-align: center;
font-weight: 500;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.1em;
color: rgba(204, 204, 220, 0.65);
text-transform: uppercase;
padding: 8px;
background: var(--secondary-background);
}
@ -82,7 +76,6 @@
line-height: 16px;
text-align: center;
padding: 12px;
color: rgba(204, 204, 220, 0.65);
cursor: pointer;
}

View file

@ -9,6 +9,7 @@ import { toJS } from 'mobx';
import { observer } from 'mobx-react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import RotationForm from 'containers/RotationForm/RotationForm';
@ -91,12 +92,16 @@ class Rotations extends Component<RotationsProps, RotationsState> {
<div className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Rotations</div>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Rotations
</Text.Title>
</div>
<ValuePicker
label="Add rotation"
options={options}
onChange={this.handleAddRotation}
variant="secondary"
variant="primary"
size="md"
/>
</HorizontalGroup>
@ -109,7 +114,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
<div id={`layer${layer.priority}`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer {layer.priority}</span>
<Text type="secondary">Layer {layer.priority}</Text>
{/*<Icon name="info-circle" />*/}
</HorizontalGroup>
</div>
@ -151,8 +156,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
<div id={`layer1`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer 1</span>
{/* <Icon name="info-circle" />*/}
<Text type="secondary">Layer 1</Text>
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
@ -182,7 +186,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
this.handleAddLayer(nextPriority);
}}
>
+ Add rotations layer
<Text type="primary">+ Add rotations layer</Text>
</div>
)}
</div>

View file

@ -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 { getColor, getFromString, getOverrideColor } from 'models/schedule/schedule.helpers';
@ -73,7 +74,11 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
{!hideHeader && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Final schedule</div>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Final schedule
</Text.Title>
</div>
{/*<Input
prefix={<Icon name="search" />}
placeholder="Search..."

View file

@ -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';
@ -69,7 +70,11 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<div id="overrides-list" className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Overrides</div>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Overrides
</Text.Title>
</div>
<Button icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>

View file

@ -166,8 +166,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<HorizontalGroup spacing="sm">
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">{dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
<Text type="secondary">{dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
<Text type="secondary">{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
<Text type="secondary">{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>

View file

@ -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%;

View file

@ -71,7 +71,11 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('title')}>Schedule team and timezones</div>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Schedule team and timezones
</Text.Title>
</div>
{/* <HorizontalGroup>
<InlineSwitch transparent />
Current schedule users only
@ -98,12 +102,18 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
'time-mark-text__translated': index > 0,
})}
>
{mm.format('HH:mm')}
<Text type="secondary" size="small">
{mm.format('HH:mm')}
</Text>
</span>
</div>
))}
<div key={jLimit} className={cx('time-mark')}>
<span className={cx('time-mark-text')}>24:00</span>
<span className={cx('time-mark-text')}>
<Text type="secondary" size="small">
24:00
</Text>
</span>
</div>
</div>
</div>

View file

@ -238,7 +238,7 @@ export const ExpandIcon = (props: IconProps) => {
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11 1.16994C10.8126 0.983692 10.5592 0.87915 10.295 0.87915C10.0308 0.87915 9.77737 0.983692 9.59001 1.16994L6.00001 4.70994L2.46001 1.16994C2.27265 0.983692 2.0192 0.87915 1.75501 0.87915C1.49082 0.87915 1.23737 0.983692 1.05001 1.16994C0.956281 1.26291 0.881887 1.37351 0.831118 1.49537C0.780349 1.61723 0.754211 1.74793 0.754211 1.87994C0.754211 2.01195 0.780349 2.14266 0.831118 2.26452C0.881887 2.38638 0.956281 2.49698 1.05001 2.58994L5.29001 6.82994C5.38297 6.92367 5.49357 6.99806 5.61543 7.04883C5.73729 7.0996 5.868 7.12574 6.00001 7.12574C6.13202 7.12574 6.26273 7.0996 6.38459 7.04883C6.50645 6.99806 6.61705 6.92367 6.71001 6.82994L11 2.58994C11.0937 2.49698 11.1681 2.38638 11.2189 2.26452C11.2697 2.14266 11.2958 2.01195 11.2958 1.87994C11.2958 1.74793 11.2697 1.61723 11.2189 1.49537C11.1681 1.37351 11.0937 1.26291 11 1.16994Z"
fill="#CCCCDC"
fill="currentColor"
fillOpacity="0.65"
/>
</svg>

View file

@ -70,6 +70,18 @@ export class AlertReceiveChannelStore extends BaseStore {
);
}
@action
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
const alertReceiveChannel = await this.getById(id, skipErrorHandling);
this.items = {
...this.items,
[id]: alertReceiveChannel
}
return alertReceiveChannel
}
@action
async updateItems(query = '') {
const result = await this.getAll(query);

View file

@ -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[] };

View file

@ -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

View file

@ -22,6 +22,18 @@ export class EscalationChainStore extends BaseStore {
this.path = '/escalation_chains/';
}
@action
async loadItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
const escalationChain = await this.getById(id, skipErrorHandling);
this.items = {
...this.items,
[id]: escalationChain
}
return escalationChain
}
@action
async updateById(id: EscalationChain['id']) {
const response = await this.getById(id);

View file

@ -19,6 +19,18 @@ export class OutgoingWebhookStore extends BaseStore {
this.path = '/custom_buttons/';
}
@action
async loadItem(id: OutgoingWebhook['id'], skipErrorHandling = false): Promise<OutgoingWebhook> {
const outgoingWebhook = await this.getById(id, skipErrorHandling);
this.items = {
...this.items,
[id]: outgoingWebhook,
};
return outgoingWebhook;
}
@action
async updateById(id: OutgoingWebhook['id']) {
const response = await this.getById(id);
@ -30,8 +42,8 @@ export class OutgoingWebhookStore extends BaseStore {
}
@action
async updateItem(id: OutgoingWebhook['id']) {
const response = await this.getById(id);
async updateItem(id: OutgoingWebhook['id'], fromOrganization = false) {
const response = await this.getById(id, false, fromOrganization);
this.items = {
...this.items,

View file

@ -82,6 +82,18 @@ export class ScheduleStore extends BaseStore {
this.path = '/schedules/';
}
@action
async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise<Schedule> {
const schedule = await this.getById(id, skipErrorHandling);
this.items = {
...this.items,
[id]: schedule,
};
return schedule;
}
@action
async updateScheduleEvents(
scheduleId: Schedule['id'],
@ -121,9 +133,9 @@ export class ScheduleStore extends BaseStore {
};
}
async updateItem(id: Schedule['id']) {
async updateItem(id: Schedule['id'], fromOrganization = false) {
if (id) {
const item = await this.getById(id);
const item = await this.getById(id, true, fromOrganization);
this.items = {
...this.items,

View file

@ -73,8 +73,8 @@ export class UserStore extends BaseStore {
}
@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,
@ -93,7 +93,7 @@ export class UserStore extends BaseStore {
this.items = {
...this.items,
[user.pk]: user,
[user.pk]: { ...user, timezone: getTimezone(user) },
};
}

View file

@ -2,17 +2,7 @@ import React from 'react';
import { AppRootProps } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
import {
Alert,
Button,
EmptySearchResult,
HorizontalGroup,
Icon,
IconButton,
LoadingPlaceholder,
Tooltip,
VerticalGroup,
} from '@grafana/ui';
import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -21,23 +11,22 @@ import Collapse from 'components/Collapse/Collapse';
import EscalationsFilters from 'components/EscalationsFilters/EscalationsFilters';
import Block from 'components/GBlock/Block';
import GList from 'components/GList/GList';
import IntegrationsFilters from 'components/IntegrationsFilters/IntegrationsFilters';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import Tutorial from 'components/Tutorial/Tutorial';
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import AlertReceiveChannelCard from 'containers/AlertReceiveChannelCard/AlertReceiveChannelCard';
import EscalationChainCard from 'containers/EscalationChainCard/EscalationChainCard';
import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainForm';
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
import GSelect from 'containers/GSelect/GSelect';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { AlertReceiveChannel } from 'models/alert_receive_channel';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { SelectOption, WithStoreProps } from 'state/types';
import { WithStoreProps } from 'state/types';
import { UserAction } from 'state/userAction';
import { withMobXProviderContext } from 'state/withStore';
import { openWarningNotification } from 'utils';
@ -48,7 +37,7 @@ const cx = cn.bind(styles);
interface EscalationChainsPageProps extends WithStoreProps, AppRootProps {}
interface EscalationChainsPageState {
interface EscalationChainsPageState extends PageBaseState {
escalationChainsFilters: { searchTerm: string };
showCreateEscalationChainModal: boolean;
escalationChainIdToCopy: EscalationChain['id'];
@ -66,36 +55,44 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
showCreateEscalationChainModal: false,
escalationChainIdToCopy: undefined,
selectedEscalationChain: undefined,
errorData: initErrorDataState(),
};
async componentDidMount() {
this.update().then(this.parseQueryParams);
}
parseQueryParams = () => {
parseQueryParams = async () => {
this.setState({ errorData: initErrorDataState() }); // reset on query parse
const { store, query } = this.props;
const { escalationChainStore } = store;
const {
escalationChainsFilters: { searchTerm },
} = this.state;
const { escalationChainStore } = store;
const searchResult = escalationChainStore.getSearchResult(searchTerm);
let selectedEscalationChain;
let selectedEscalationChain: EscalationChain['id'];
if (query.id) {
const escalationChain = escalationChainStore.items[query.id];
let escalationChain = await escalationChainStore
.loadItem(query.id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
if (!escalationChain) {
return;
}
escalationChain = escalationChainStore.items[query.id];
if (escalationChain) {
selectedEscalationChain = escalationChain.id;
} else {
openWarningNotification(
`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`
);
}
}
if (!selectedEscalationChain) {
selectedEscalationChain = searchResult[0]?.id;
}
this.setSelectedEscalationChain(selectedEscalationChain);
};
@ -115,97 +112,111 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
update = () => {
const { store } = this.props;
return store.escalationChainStore.updateItems();
return store.escalationChainStore.updateItems('');
};
componentDidUpdate() {}
componentDidUpdate(prevProps: EscalationChainsPageProps) {
if (this.props.query.id !== prevProps.query.id) {
this.parseQueryParams();
}
}
render() {
const { store } = this.props;
const { store, query } = this.props;
const {
showCreateEscalationChainModal,
escalationChainIdToCopy,
escalationChainsFilters,
selectedEscalationChain,
errorData,
} = this.state;
const { escalationChainStore } = store;
const searchResult = escalationChainStore.getSearchResult(escalationChainsFilters.searchTerm);
return (
<>
<div className={cx('root')}>
<div className={cx('filters')}>
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
</div>
{!searchResult || searchResult.length ? (
<div className={cx('escalations')}>
<div className={cx('left-column')}>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
}}
icon="plus"
className={cx('new-escalation-chain')}
>
New escalation chain
</Button>
</WithPermissionControl>
<div className={cx('escalations-list')}>
{searchResult ? (
<GList
autoScroll
selectedId={selectedEscalationChain}
items={searchResult}
itemKey="id"
onSelect={this.setSelectedEscalationChain}
>
{(item) => <EscalationChainCard id={item.id} />}
</GList>
) : (
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
)}
</div>
<PageErrorHandlingWrapper
errorData={errorData}
objectName="escalation"
pageName="escalations"
itemNotFoundMessage={`Escalation chain with id=${query?.id} is not found. Please select escalation chain from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<div className={cx('filters')}>
<EscalationsFilters value={escalationChainsFilters} onChange={this.handleEscalationsFiltersChange} />
</div>
<div className={cx('escalation')}>{this.renderEscalation()}</div>
{!searchResult || searchResult.length ? (
<div className={cx('escalations')}>
<div className={cx('left-column')}>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
}}
icon="plus"
className={cx('new-escalation-chain')}
>
New escalation chain
</Button>
</WithPermissionControl>
<div className={cx('escalations-list')}>
{searchResult ? (
<GList
autoScroll
selectedId={selectedEscalationChain}
items={searchResult}
itemKey="id"
onSelect={this.setSelectedEscalationChain}
>
{(item) => <EscalationChainCard id={item.id} />}
</GList>
) : (
<LoadingPlaceholder className={cx('loading')} text="Loading..." />
)}
</div>
</div>
<div className={cx('escalation')}>{this.renderEscalation()}</div>
</div>
) : (
<Tutorial
step={TutorialStep.Escalations}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
<Button
icon="plus"
variant="primary"
size="lg"
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
}}
>
New Escalation Chain
</Button>
</WithPermissionControl>
</VerticalGroup>
}
/>
)}
</div>
) : (
<Tutorial
step={TutorialStep.Escalations}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">No escalations found, check your filtering and current team.</Text>
<WithPermissionControl userAction={UserAction.UpdateEscalationPolicies}>
<Button
icon="plus"
variant="primary"
size="lg"
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
}}
>
New Escalation Chain
</Button>
</WithPermissionControl>
</VerticalGroup>
}
/>
)}
</div>
{showCreateEscalationChainModal && (
<EscalationChainForm
escalationChainId={escalationChainIdToCopy}
onHide={() => {
this.setState({
showCreateEscalationChainModal: false,
escalationChainIdToCopy: undefined,
});
}}
onUpdate={this.handleEscalationChainCreate}
/>
{showCreateEscalationChainModal && (
<EscalationChainForm
escalationChainId={escalationChainIdToCopy}
onHide={() => {
this.setState({
showCreateEscalationChainModal: false,
escalationChainIdToCopy: undefined,
});
}}
onUpdate={this.handleEscalationChainCreate}
/>
)}
</>
)}
</>
</PageErrorHandlingWrapper>
);
}

View file

@ -26,7 +26,11 @@ import reactStringReplace from 'react-string-replace';
import Collapse from 'components/Collapse/Collapse';
import Block from 'components/GBlock/Block';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import WrongTeamStub from 'components/NotFoundInTeam/WrongTeamStub';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
@ -58,13 +62,9 @@ const cx = cn.bind(styles);
interface IncidentPageProps extends WithStoreProps, AppRootProps {}
interface IncidentPageState {
interface IncidentPageState extends PageBaseState {
showIntegrationSettings?: boolean;
showAttachIncidentForm?: boolean;
notFound?: boolean;
wrongTeamError?: boolean;
wrongTeamNoPermissions?: boolean;
teamToSwitch?: { name: string; id: string };
timelineFilter: string;
resolutionNoteText: string;
}
@ -74,8 +74,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
state: IncidentPageState = {
timelineFilter: 'all',
resolutionNoteText: '',
wrongTeamError: false,
wrongTeamNoPermissions: false,
errorData: initErrorDataState(),
};
componentDidMount() {
@ -93,28 +92,16 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
}
update = () => {
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false
const {
store,
query: { id },
} = this.props;
store.alertGroupStore.getAlert(id).catch((error) => {
if (error.response) {
if (error.response.status === 404) {
this.setState({ notFound: true });
} else if (error.response.status === 403 && error.response.data.error_code === 'wrong_team') {
let res = error.response.data;
if (res.owner_team) {
this.setState({ wrongTeamError: true, teamToSwitch: { name: res.owner_team.name, id: res.owner_team.id } });
} else {
this.setState({ wrongTeamError: true, wrongTeamNoPermissions: true });
}
return;
}
}
this.setState({ notFound: true });
});
store.alertGroupStore
.getAlert(id)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
};
render() {
@ -123,53 +110,14 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
query: { id, cursor, start, perpage },
} = this.props;
const {
showIntegrationSettings,
showAttachIncidentForm,
notFound,
wrongTeamError,
teamToSwitch,
wrongTeamNoPermissions,
} = this.state;
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
const { isNotFoundError, isWrongTeamError } = errorData;
const { alertReceiveChannelStore } = store;
const { alerts } = store.alertGroupStore;
const incident = alerts.get(id);
const currentTeamId = store.userStore.currentUser?.current_team;
const currentTeamName = store.grafanaTeamStore.items[currentTeamId]?.name;
if (notFound) {
return (
<div className={cx('root')}>
<div className={cx('not-found')}>
<VerticalGroup spacing="lg" align="center">
<Text.Title level={1}>404</Text.Title>
<Text.Title level={4}>Incident not found</Text.Title>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<Button variant="secondary" icon="arrow-left" size="md">
Go to incidents page
</Button>
</PluginLink>
</VerticalGroup>
</div>
</div>
);
}
if (wrongTeamError) {
return (
<WrongTeamStub
objectName="alert group"
pageName="incidents"
currentTeam={currentTeamName}
switchToTeam={teamToSwitch}
wrongTeamNoPermissions={wrongTeamNoPermissions}
/>
);
}
if (!incident) {
if (!incident && !isNotFoundError && !isWrongTeamError) {
return (
<div className={cx('root')}>
<LoadingPlaceholder text="Loading alert group..." />
@ -178,48 +126,75 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
}
return (
<>
<div className={cx('root')}>
{this.renderHeader()}
<div className={cx('content')}>
<div className={cx('column')}>
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
<GroupedIncidentsList id={incident.pk} getIncidentDatetimeReference={this.getIncidentDatetimeReference} />
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
<PageErrorHandlingWrapper
errorData={errorData}
objectName="alert group"
pageName="incidents"
>
{() =>
errorData.isNotFoundError ? (
<div className={cx('root')}>
<div className={cx('not-found')}>
<VerticalGroup spacing="lg" align="center">
<Text.Title level={1}>404</Text.Title>
<Text.Title level={4}>Incident not found</Text.Title>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<Button variant="secondary" icon="arrow-left" size="md">
Go to incidents page
</Button>
</PluginLink>
</VerticalGroup>
</div>
</div>
<div className={cx('column')}>{this.renderTimeline()}</div>
</div>
</div>
{showIntegrationSettings && (
<IntegrationSettings
alertGroupId={incident.pk}
onUpdate={() => {
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
}}
onUpdateTemplates={() => {
store.alertGroupStore.getAlert(id);
}}
startTab={IntegrationSettingsTab.Templates}
id={incident.alert_receive_channel.id}
onHide={() =>
this.setState({
showIntegrationSettings: undefined,
})
}
/>
)}
{showAttachIncidentForm && (
<AttachIncidentForm
id={id}
onHide={() => {
this.setState({
showAttachIncidentForm: false,
});
}}
onUpdate={this.update}
/>
)}
</>
) : (
<>
<div className={cx('root')}>
{this.renderHeader()}
<div className={cx('content')}>
<div className={cx('column')}>
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
<GroupedIncidentsList
id={incident.pk}
getIncidentDatetimeReference={this.getIncidentDatetimeReference}
/>
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
</div>
<div className={cx('column')}>{this.renderTimeline()}</div>
</div>
</div>
{showIntegrationSettings && (
<IntegrationSettings
alertGroupId={incident.pk}
onUpdate={() => {
alertReceiveChannelStore.updateItem(incident.alert_receive_channel.id);
}}
onUpdateTemplates={() => {
store.alertGroupStore.getAlert(id);
}}
startTab={IntegrationSettingsTab.Templates}
id={incident.alert_receive_channel.id}
onHide={() =>
this.setState({
showIntegrationSettings: undefined,
})
}
/>
)}
{showAttachIncidentForm && (
<AttachIncidentForm
id={id}
onHide={() => {
this.setState({
showAttachIncidentForm: false,
});
}}
onUpdate={this.update}
/>
)}
</>
)
}
</PageErrorHandlingWrapper>
);
}
@ -297,7 +272,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
Copy Link
</Button>
</CopyToClipboard>
<a href={incident.permalink} target="_blank" rel="noreferrer">
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
<Button variant="primary" size="sm" icon="slack">
View in Slack
</Button>

View file

@ -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';

View file

@ -9,6 +9,11 @@ import { observer } from 'mobx-react';
import GList from 'components/GList/GList';
import IntegrationsFilters, { Filters } from 'components/IntegrationsFilters/IntegrationsFilters';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import Text from 'components/Text/Text';
import Tutorial from 'components/Tutorial/Tutorial';
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
@ -29,7 +34,7 @@ import styles from './Integrations.module.css';
const cx = cn.bind(styles);
interface IntegrationsState {
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
showCreateIntegrationModal: boolean;
alertReceiveChannelToShowSettings?: AlertReceiveChannel['id'];
@ -43,6 +48,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
state: IntegrationsState = {
integrationsFilters: { searchTerm: '' },
showCreateIntegrationModal: false,
errorData: initErrorDataState(),
};
alertReceiveChanneltoPoll: { [key: string]: number } = {};
@ -58,30 +64,38 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
getLocationSrv().update({ partial: true, query: { id: alertReceiveChannelId } });
};
parseQueryParams = () => {
const { store, query } = this.props;
parseQueryParams = async () => {
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse // reset wrong team error to false
const { store, query } = this.props;
const { alertReceiveChannelStore } = store;
const searchResult = alertReceiveChannelStore.getSearchResult();
let selectedAlertReceiveChannel = store.selectedAlertReceiveChannel;
if (query.id) {
const alertReceiveChannelId = searchResult && searchResult.find((res) => res.id === query?.id)?.id;
if (alertReceiveChannelId) {
selectedAlertReceiveChannel = alertReceiveChannelId;
} else {
openWarningNotification(
`Integration with id=${query?.id} is not found. Please select integration from the list.`
);
let alertReceiveChannel = await alertReceiveChannelStore
.loadItem(query.id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
if (!alertReceiveChannel) {
return;
}
if (alertReceiveChannel.id) {
selectedAlertReceiveChannel = alertReceiveChannel.id;
}
if (query.tab) {
this.setState({ integrationSettingsTab: query.tab });
this.setState({ alertReceiveChannelToShowSettings: query.id });
}
}
if (!selectedAlertReceiveChannel) {
selectedAlertReceiveChannel = searchResult[0]?.id;
}
this.setSelectedAlertReceiveChannel(selectedAlertReceiveChannel);
};
@ -104,127 +118,135 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
render() {
const { store } = this.props;
const {
integrationsFilters: { searchTerm },
} = this.state;
const { store, query } = this.props;
const {
integrationsFilters,
alertReceiveChannelToShowSettings,
integrationSettingsTab,
showCreateIntegrationModal,
errorData,
} = this.state;
const { alertReceiveChannelStore } = store;
const searchResult = alertReceiveChannelStore.getSearchResult();
return (
<>
<div className={cx('root')}>
<div className={cx('filters')}>
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
</div>
{searchResult?.length ? (
<div className={cx('integrations')}>
<div className={cx('integrationsList')}>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
onClick={() => {
this.setState({ showCreateIntegrationModal: true });
}}
icon="plus"
className={cx('newIntegrationButton')}
>
New integration for receiving alerts
</Button>
</WithPermissionControl>
<div className={cx('alert-receive-channels-list')}>
<GList
autoScroll
selectedId={store.selectedAlertReceiveChannel}
items={searchResult}
itemKey="id"
onSelect={this.handleAlertReceiveChannelSelect}
>
{(item) => (
<AlertReceiveChannelCard
id={item.id}
onShowHeartbeatModal={() => {
this.setState({
alertReceiveChannelToShowSettings: item.id,
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
});
}}
/>
)}
</GList>
</div>
<PageErrorHandlingWrapper
errorData={errorData}
objectName="integration"
pageName="integrations"
itemNotFoundMessage={`Integration with id=${query?.id} is not found. Please select integration from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<div className={cx('filters')}>
<IntegrationsFilters value={integrationsFilters} onChange={this.handleIntegrationsFiltersChange} />
</div>
<div className={cx('alert-rules', 'alertRulesBorder')}>
<AlertRules
alertReceiveChannelId={store.selectedAlertReceiveChannel}
onDelete={this.handleDeleteAlertReceiveChannel}
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
this.setState({
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
integrationSettingsTab,
});
}}
/*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler(
{searchResult?.length ? (
<div className={cx('integrations')}>
<div className={cx('integrationsList')}>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
onClick={() => {
this.setState({ showCreateIntegrationModal: true });
}}
icon="plus"
className={cx('newIntegrationButton')}
>
New integration for receiving alerts
</Button>
</WithPermissionControl>
<div className={cx('alert-receive-channels-list')}>
<GList
autoScroll
selectedId={store.selectedAlertReceiveChannel}
items={searchResult}
itemKey="id"
onSelect={this.handleAlertReceiveChannelSelect}
>
{(item) => (
<AlertReceiveChannelCard
id={item.id}
onShowHeartbeatModal={() => {
this.setState({
alertReceiveChannelToShowSettings: item.id,
integrationSettingsTab: IntegrationSettingsTab.Heartbeat,
});
}}
/>
)}
</GList>
</div>
</div>
<div className={cx('alert-rules', 'alertRulesBorder')}>
<AlertRules
alertReceiveChannelId={store.selectedAlertReceiveChannel}
onDelete={this.handleDeleteAlertReceiveChannel}
onShowSettings={(integrationSettingsTab?: IntegrationSettingsTab) => {
this.setState({
alertReceiveChannelToShowSettings: store.selectedAlertReceiveChannel,
integrationSettingsTab,
});
}}
/*onEditAlertReceiveChannelTemplates={this.getShowAlertReceiveChannelSettingsClickHandler(
store.selectedAlertReceiveChannel
)}*/
/>
</div>
</div>
) : searchResult ? (
<Tutorial
step={TutorialStep.Integrations}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
icon="plus"
variant="primary"
size="lg"
onClick={() => {
this.setState({ showCreateIntegrationModal: true });
}}
>
New integration for receiving alerts
</Button>
</WithPermissionControl>
</VerticalGroup>
}
/>
</div>
) : (
<LoadingPlaceholder text="Loading..." />
)}
</div>
) : searchResult ? (
<Tutorial
step={TutorialStep.Integrations}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">No integrations found. Review your filter and team settings.</Text>
<WithPermissionControl userAction={UserAction.UpdateAlertReceiveChannels}>
<Button
icon="plus"
variant="primary"
size="lg"
onClick={() => {
this.setState({ showCreateIntegrationModal: true });
}}
>
New integration for receiving alerts
</Button>
</WithPermissionControl>
</VerticalGroup>
}
/>
) : (
<LoadingPlaceholder text="Loading..." />
)}
</div>
{alertReceiveChannelToShowSettings && (
<IntegrationSettings
onUpdate={() => {
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
}}
startTab={integrationSettingsTab}
id={alertReceiveChannelToShowSettings}
onHide={() => {
this.setState({
alertReceiveChannelToShowSettings: undefined,
integrationSettingsTab: undefined,
});
getLocationSrv().update({ partial: true, query: { tab: undefined } });
}}
/>
{alertReceiveChannelToShowSettings && (
<IntegrationSettings
onUpdate={() => {
alertReceiveChannelStore.updateItem(alertReceiveChannelToShowSettings);
}}
startTab={integrationSettingsTab}
id={alertReceiveChannelToShowSettings}
onHide={() => {
this.setState({
alertReceiveChannelToShowSettings: undefined,
integrationSettingsTab: undefined,
});
getLocationSrv().update({ partial: true, query: { tab: undefined } });
}}
/>
)}
{showCreateIntegrationModal && (
<CreateAlertReceiveChannelContainer
onHide={() => {
this.setState({ showCreateIntegrationModal: false });
}}
onCreate={this.handleCreateNewAlertReceiveChannel}
/>
)}
</>
)}
{showCreateIntegrationModal && (
<CreateAlertReceiveChannelContainer
onHide={() => {
this.setState({ showCreateIntegrationModal: false });
}}
onCreate={this.handleCreateNewAlertReceiveChannel}
/>
)}
</>
</PageErrorHandlingWrapper>
);
}

View file

@ -7,15 +7,18 @@ import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import GTable from 'components/GTable/GTable';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import GSelect from 'containers/GSelect/GSelect';
import OutgoingWebhookForm from 'containers/OutgoingWebhookForm/OutgoingWebhookForm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { ActionDTO } from 'models/action';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
import { WithStoreProps } from 'state/types';
import { UserAction } from 'state/userAction';
import { withMobXProviderContext } from 'state/withStore';
@ -26,13 +29,15 @@ const cx = cn.bind(styles);
interface OutgoingWebhooksProps extends WithStoreProps, AppRootProps {}
interface OutgoingWebhooksState {
interface OutgoingWebhooksState extends PageBaseState {
outgoingWebhookIdToEdit?: OutgoingWebhook['id'] | 'new';
}
@observer
class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWebhooksState> {
state: OutgoingWebhooksState = {};
state: OutgoingWebhooksState = {
errorData: initErrorDataState(),
};
async componentDidMount() {
this.update().then(this.parseQueryParams);
@ -44,27 +49,37 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
}
}
parseQueryParams = () => {
parseQueryParams = async () => {
this.setState((prevState) => ({
errorData: initErrorDataState(),
outgoingWebhookIdToEdit: undefined,
})); // reset state on query parse
const {
store,
query: { id },
} = this.props;
if (id) {
this.setState({ outgoingWebhookIdToEdit: id });
const outgoingWebhook = await store.outgoingWebhookStore
.loadItem(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
if (outgoingWebhook) {
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 +102,48 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
];
return (
<>
<div className={cx('root')}>
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
<PluginLink
partial
query={{ id: 'new' }}
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
>
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
<Button variant="primary" icon="plus">
Create
</Button>
</WithPermissionControl>
</PluginLink>
</div>
<PageErrorHandlingWrapper
errorData={errorData}
objectName="outgoing webhook"
pageName="outgoing_webhooks"
itemNotFoundMessage={`Outgoing webhook with id=${query?.id} is not found. Please select outgoing webhook from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<Text.Title level={3}>Outgoing Webhooks</Text.Title>
<PluginLink
partial
query={{ id: 'new' }}
disabled={!store.isUserActionAllowed(UserAction.UpdateCustomActions)}
>
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
<Button variant="primary" icon="plus">
Create
</Button>
</WithPermissionControl>
</PluginLink>
</div>
)}
rowKey="id"
columns={columns}
data={webhooks}
/>
</div>
{outgoingWebhookIdToEdit && (
<OutgoingWebhookForm
id={outgoingWebhookIdToEdit}
onUpdate={this.update}
onHide={this.handleOutgoingWebhookFormHide}
/>
)}
rowKey="id"
columns={columns}
data={webhooks}
/>
</div>
{outgoingWebhookIdToEdit && (
<OutgoingWebhookForm
id={outgoingWebhookIdToEdit}
onUpdate={this.update}
onHide={this.handleOutgoingWebhookFormHide}
/>
</>
)}
</>
</PageErrorHandlingWrapper>
);
}

View file

@ -5,7 +5,7 @@
margin-top: 24px;
--rotations-border: var(--border-medium);
--rotations-background: var(--primary-background);
--rotations-background: var(--background-secondary);
}
.header {

View file

@ -116,7 +116,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<PluginLink query={{ page: 'schedules-new' }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
</PluginLink>
<Text.Title editable editModalTitle="Schedule name" level={3} onTextChange={this.handleNameChange}>
<Text.Title editable editModalTitle="Schedule name" level={2} onTextChange={this.handleNameChange}>
{schedule?.name}
</Text.Title>
{/*<ScheduleCounter
@ -186,9 +186,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<Icon name="angle-right" />
</Button>
</HorizontalGroup>
<div>
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</div>
</Text.Title>
</HorizontalGroup>
{/*<HorizontalGroup width="auto">
<RadioButtonGroup

View file

@ -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<SchedulesPageProps, SchedulesPageSta
selectedDate: moment().startOf('day').format('YYYY-MM-DD'),
},
expandedSchedulesKeys: [],
errorData: initErrorDataState(),
};
componentDidMount() {
@ -71,13 +77,22 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
}
}
parseQueryParams = () => {
parseQueryParams = async () => {
this.setState({ errorData: initErrorDataState() }); // reset wrong team error to false on query parse
const {
store,
query: { id },
} = this.props;
if (id) {
const schedule = await store.scheduleStore
.loadItem(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
if (!schedule) {
return;
}
const schedules = store.scheduleStore.getSearchResult();
const scheduleId = schedules && schedules.find((res) => res.id === id)?.id;
if (scheduleId || id === 'new') {
@ -96,10 +111,10 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
render() {
const { store } = this.props;
const { store, query } = this.props;
const { expandedSchedulesKeys, scheduleIdToDelete, scheduleIdToEdit, scheduleIdToExport } = this.state;
const { filters } = this.state;
const { scheduleStore, userStore } = store;
const { filters, errorData } = this.state;
const { scheduleStore } = store;
const columns = [
{
@ -145,107 +160,118 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const timezoneStr = moment.tz.guess();
const offset = moment().tz(timezoneStr).format('Z');
if (schedules && !schedules.length) {
}
return (
<>
<div className={cx('root')}>
<div className={cx('title')}>
<HorizontalGroup align="flex-end">
<Text.Title level={3}>On-call Schedules</Text.Title>
<Text type="secondary">
Use this to distribute notifications among team members you specified in the "Notify Users from on-call
schedule" step in <PluginLink query={{ page: 'integrations' }}>escalation chains</PluginLink>.
</Text>
</HorizontalGroup>
</div>
{!schedules || schedules.length ? (
<GTable
emptyText={schedules ? 'No schedules found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<HorizontalGroup className={cx('filters')} spacing="md">
<SchedulesFilters value={filters} onChange={this.handleChangeFilters} />
<Text type="secondary">
<Icon name="info-circle" /> Your timezone is {timezoneStr} UTC{offset}
</Text>
</HorizontalGroup>
<PluginLink
partial
query={{ id: 'new' }}
disabled={!store.isUserActionAllowed(UserAction.UpdateSchedules)}
>
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus">
New schedule
</Button>
</WithPermissionControl>
</PluginLink>
</div>
<PageErrorHandlingWrapper
errorData={errorData}
objectName="schedule"
pageName="schedules"
itemNotFoundMessage={`Schedule with id=${query?.id} is not found. Please select schedule from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<div className={cx('title')}>
<HorizontalGroup align="flex-end">
<Text.Title level={3}>On-call Schedules</Text.Title>
<Text type="secondary">
Use this to distribute notifications among team members you specified in the "Notify Users from
on-call schedule" step in{' '}
<PluginLink query={{ page: 'integrations' }}>escalation chains</PluginLink>.
</Text>
</HorizontalGroup>
</div>
{!schedules || schedules.length ? (
<GTable
emptyText={schedules ? 'No schedules found' : 'Loading...'}
title={() => (
<div className={cx('header')}>
<HorizontalGroup className={cx('filters')} spacing="md">
<SchedulesFilters value={filters} onChange={this.handleChangeFilters} />
<Text type="secondary">
<Icon name="info-circle" /> Your timezone is {timezoneStr} UTC{offset}
</Text>
</HorizontalGroup>
<PluginLink
partial
query={{ id: 'new' }}
disabled={!store.isUserActionAllowed(UserAction.UpdateSchedules)}
>
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus">
New schedule
</Button>
</WithPermissionControl>
</PluginLink>
</div>
)}
rowKey="id"
columns={columns}
data={schedules}
expandable={{
expandedRowRender: this.renderEvents,
expandRowByClick: true,
onExpand: this.onRowExpand,
expandedRowKeys: expandedSchedulesKeys,
onExpandedRowsChange: this.handleExpandedRowsChange,
}}
/>
) : (
<Tutorial
step={TutorialStep.Schedules}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">You havent added a schedule yet.</Text>
<PluginLink partial query={{ id: 'new' }}>
<Button icon="plus" variant="primary" size="lg">
Add team schedule for on-call rotation
</Button>
</PluginLink>
</VerticalGroup>
}
/>
)}
rowKey="id"
columns={columns}
data={schedules}
expandable={{
expandedRowRender: this.renderEvents,
expandRowByClick: true,
onExpand: this.onRowExpand,
expandedRowKeys: expandedSchedulesKeys,
onExpandedRowsChange: this.handleExpandedRowsChange,
}}
/>
) : (
<Tutorial
step={TutorialStep.Schedules}
title={
<VerticalGroup align="center" spacing="lg">
<Text type="secondary">You havent added a schedule yet.</Text>
<PluginLink partial query={{ id: 'new' }}>
<Button icon="plus" variant="primary" size="lg">
Add team schedule for on-call rotation
</Button>
</PluginLink>
</VerticalGroup>
}
/>
)}
</div>
{scheduleIdToEdit && (
<ScheduleForm
id={scheduleIdToEdit}
type={ScheduleType.Ical}
onUpdate={this.update}
onHide={() => {
this.setState({ scheduleIdToEdit: undefined });
getLocationSrv().update({ partial: true, query: { id: undefined } });
}}
/>
</div>
{scheduleIdToEdit && (
<ScheduleForm
id={scheduleIdToEdit}
type={ScheduleType.Ical}
onUpdate={this.update}
onHide={() => {
this.setState({ scheduleIdToEdit: undefined });
getLocationSrv().update({ partial: true, query: { id: undefined } });
}}
/>
)}
{scheduleIdToDelete && (
<ConfirmModal
isOpen
title="Are you sure to delete?"
confirmText="Delete"
dismissText="Cancel"
onConfirm={this.handleDelete}
body={null}
onDismiss={() => {
this.setState({ scheduleIdToDelete: undefined });
}}
/>
)}
{scheduleIdToExport && (
<Modal
isOpen
title="Schedule export"
closeOnEscape
onDismiss={() => this.setState({ scheduleIdToExport: undefined })}
>
<ScheduleICalSettings id={scheduleIdToExport} />
</Modal>
)}
</>
)}
{scheduleIdToDelete && (
<ConfirmModal
isOpen
title="Are you sure to delete?"
confirmText="Delete"
dismissText="Cancel"
onConfirm={this.handleDelete}
body={null}
onDismiss={() => {
this.setState({ scheduleIdToDelete: undefined });
}}
/>
)}
{scheduleIdToExport && (
<Modal
isOpen
title="Schedule export"
closeOnEscape
onDismiss={() => this.setState({ scheduleIdToExport: undefined })}
>
<ScheduleICalSettings id={scheduleIdToExport} />
</Modal>
)}
</>
</PageErrorHandlingWrapper>
);
}

View file

@ -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<UsersProps, UsersState> {
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<UsersProps, UsersState> {
}
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<UsersProps, UsersState> {
};
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<UsersProps, UsersState> {
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<UsersProps, UsersState> {
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<UsersProps, UsersState> {
const { count, results } = userStore.getSearchResult();
return (
<div className={cx('root')}>
<div className={cx('root', 'TEST-users-page')}>
<div className={cx('users-header')}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<div>
<Text.Title level={3}>Users</Text.Title>
<Text type="secondary">
To manage permissions or add users, please visit <a href="/org/users">Grafana user management</a>
</Text>
</div>
</div>
<PluginLink partial query={{ id: 'me' }}>
<Button variant="primary" icon="user">
View my profile
</Button>
</PluginLink>
</div>
{store.isUserActionAllowed(UserAction.ViewOtherUsers) ? (
<>
<div className={cx('user-filters-container')}>
<UsersFilters
className={cx('users-filters')}
value={usersFilters}
onChange={this.handleUsersFiltersChange}
/>
<Button variant="secondary" icon="times" onClick={handleClear} className={cx('searchIntegrationClear')}>
Clear filters
</Button>
</div>
<PageErrorHandlingWrapper
errorData={errorData}
objectName="user"
pageName="users"
itemNotFoundMessage={`User with id=${query?.id} is not found. Please select user from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<div className={cx('root', 'TEST-users-page')}>
<div className={cx('users-header')}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<div>
<Text.Title level={3}>Users</Text.Title>
<Text type="secondary">
To manage permissions or add users, please visit{' '}
<a href="/org/users">Grafana user management</a>
</Text>
</div>
</div>
<PluginLink partial query={{ id: 'me' }}>
<Button variant="primary" icon="user">
View my profile
</Button>
</PluginLink>
</div>
{store.isUserActionAllowed(UserAction.ViewOtherUsers) ? (
<>
<div className={cx('user-filters-container')}>
<UsersFilters
className={cx('users-filters')}
value={usersFilters}
onChange={this.handleUsersFiltersChange}
/>
<Button
variant="secondary"
icon="times"
onClick={handleClear}
className={cx('searchIntegrationClear')}
>
Clear filters
</Button>
</div>
<GTable
emptyText={results ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
pagination={{
page,
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
/>
</>
) : (
<Alert
/* @ts-ignore */
title={
<>
You don't have enough permissions to view other users because you are not Admin.{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
</>
}
severity="info"
/>
)}
</div>
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
</div>
<GTable
emptyText={results ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
pagination={{
page,
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
/>
</>
) : (
<Alert
/* @ts-ignore */
title={
<>
You don't have enough permissions to view other users because you are not Admin.{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
</>
}
severity="info"
/>
)}
</div>
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
</div>
</>
)}
</PageErrorHandlingWrapper>
);
}

View file

@ -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);
}

View file

@ -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==