commit
4f05568007
130 changed files with 2579 additions and 1819 deletions
|
|
@ -53,7 +53,7 @@ steps:
|
|||
- name: Lint Backend
|
||||
image: python:3.11.4
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
DJANGO_SETTINGS_MODULE: settings.ci_test
|
||||
commands:
|
||||
- pip install $(grep "pre-commit==" engine/requirements-dev.txt)
|
||||
- pre-commit run isort --all-files
|
||||
|
|
@ -64,7 +64,7 @@ steps:
|
|||
image: python:3.11.4
|
||||
environment:
|
||||
RABBITMQ_URI: amqp://rabbitmq:rabbitmq@rabbit_test:5672
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
DJANGO_SETTINGS_MODULE: settings.ci_test
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
commands:
|
||||
- apt-get update && apt-get install -y netcat-traditional
|
||||
|
|
@ -386,4 +386,4 @@ name: cloud_access_policy_token
|
|||
|
||||
---
|
||||
kind: signature
|
||||
hmac: 7bf9c1d378bf2a93cb758436de78878f9a49a8501b5d1b199c412198439d3593
|
||||
hmac: c3043848d6057dfa6fb59d49459af1cbc0d013a697fd84a1329c444a6beb8ce1
|
||||
|
|
|
|||
38
.github/actions/install-frontend-dependencies/action.yml
vendored
Normal file
38
.github/actions/install-frontend-dependencies/action.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: "Install frontend dependencies"
|
||||
description: "Setup node + install frontend dependencies"
|
||||
inputs:
|
||||
working-directory:
|
||||
description: "Relative path to oncall/grafana-plugin directory"
|
||||
required: false
|
||||
default: "."
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Determine grafana-plugin directory location
|
||||
id: grafana-plugin-directory
|
||||
shell: bash
|
||||
run: echo "grafana-plugin-directory=${{ inputs.working-directory }}/grafana-plugin" >> $GITHUB_OUTPUT
|
||||
- name: Determine yarn.lock location
|
||||
id: yarn-lock-location
|
||||
shell: bash
|
||||
# yamllint disable rule:line-length
|
||||
run: echo "yarn-lock-location=${{ steps.grafana-plugin-directory.outputs.grafana-plugin-directory }}/yarn.lock" >> $GITHUB_OUTPUT
|
||||
# yamllint enable rule:line-length
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
cache: "yarn"
|
||||
cache-dependency-path: ${{ steps.yarn-lock-location.outputs.yarn-lock-location }}
|
||||
- name: Use cached frontend dependencies
|
||||
id: cache-frontend-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ inputs.working-directory }}/grafana-plugin/node_modules
|
||||
# yamllint disable rule:line-length
|
||||
key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles(steps.yarn-lock-location.outputs.yarn-lock-location) }}
|
||||
# yamllint enable rule:line-length
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
working-directory: ${{ steps.grafana-plugin-directory.outputs.grafana-plugin-directory }}
|
||||
run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000
|
||||
27
.github/actions/setup-python/action.yml
vendored
Normal file
27
.github/actions/setup-python/action.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: "Setup Python"
|
||||
description: "Setup Python + optionally install dependencies from a set of requirements file(s)"
|
||||
inputs:
|
||||
install-dependencies:
|
||||
description: "Whether to install dependencies from the Python requirements file(s)"
|
||||
required: false
|
||||
default: "true"
|
||||
python-requirements-paths:
|
||||
description: "The path(s) to the Python requirements file(s) to install"
|
||||
required: false
|
||||
default: "engine/requirements.txt engine/requirements-dev.txt"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ${{ inputs.python-requirements-paths }}
|
||||
- name: Install Python dependencies
|
||||
if: ${{ inputs.install-dependencies == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip sync --system ${{ inputs.python-requirements-paths }}
|
||||
19
.github/workflows/e2e-tests.yml
vendored
19
.github/workflows/e2e-tests.yml
vendored
|
|
@ -59,11 +59,8 @@ jobs:
|
|||
config: ./dev/kind.yml
|
||||
install_only: true
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
cache: "yarn"
|
||||
cache-dependency-path: grafana-plugin/yarn.lock
|
||||
- name: Install frontend dependencies
|
||||
uses: ./.github/actions/install-frontend-dependencies
|
||||
|
||||
- name: Install Tilt
|
||||
run: |
|
||||
|
|
@ -76,18 +73,6 @@ jobs:
|
|||
curl -fsSL https://github.com/tilt-dev/ctlptl/releases/download/v$CTLPTL_VERSION/$CTLPTL_FILE_NAME | \
|
||||
tar -xzv -C /usr/local/bin ctlptl
|
||||
|
||||
- name: Use cached frontend dependencies
|
||||
id: cache-frontend-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: grafana-plugin/node_modules
|
||||
key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true'
|
||||
working-directory: grafana-plugin
|
||||
run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000
|
||||
|
||||
- name: Use cached plugin frontend build
|
||||
id: cache-plugin-frontend
|
||||
uses: actions/cache@v3
|
||||
|
|
|
|||
2
.github/workflows/expensive-e2e-tests.yml
vendored
2
.github/workflows/expensive-e2e-tests.yml
vendored
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
post-status-to-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: end-to-end-tests
|
||||
if: failure
|
||||
if: failure()
|
||||
steps:
|
||||
# Useful references
|
||||
# https://stackoverflow.com/questions/59073850/github-actions-get-url-of-test-build
|
||||
|
|
|
|||
169
.github/workflows/linting-and-tests.yml
vendored
169
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -11,6 +11,12 @@ name: Linting and Tests
|
|||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions
|
||||
merge_group:
|
||||
|
||||
env:
|
||||
DJANGO_SETTINGS_MODULE: settings.ci_test
|
||||
DATABASE_HOST: localhost
|
||||
RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
|
||||
concurrency:
|
||||
# Cancel any running workflow for the same branch when new commits are pushed.
|
||||
# We group both by ref_name (available when CI is triggered by a push to a branch/tag)
|
||||
|
|
@ -23,52 +29,24 @@ jobs:
|
|||
name: "Lint entire project"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
# following 2 steps - need to install the frontend dependencies for the eslint/prettier/stylelint steps
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
cache: "yarn"
|
||||
cache-dependency-path: grafana-plugin/yarn.lock
|
||||
- name: Use cached frontend dependencies
|
||||
id: cache-frontend-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: grafana-plugin/node_modules
|
||||
key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }}
|
||||
install-dependencies: "false"
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true'
|
||||
working-directory: grafana-plugin
|
||||
run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000
|
||||
uses: ./.github/actions/install-frontend-dependencies
|
||||
- uses: pre-commit/action@v3.0.0
|
||||
|
||||
lint-test-and-build-frontend:
|
||||
name: "Lint, test, and build frontend"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
cache: "yarn"
|
||||
cache-dependency-path: grafana-plugin/yarn.lock
|
||||
- name: Use cached frontend dependencies
|
||||
id: cache-frontend-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: grafana-plugin/node_modules
|
||||
key: ${{ runner.os }}-frontend-node-modules-${{ hashFiles('grafana-plugin/yarn.lock') }}
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-dependencies.outputs.cache-hit != 'true'
|
||||
working-directory: grafana-plugin
|
||||
run: yarn install --frozen-lockfile --prefer-offline --network-timeout 500000
|
||||
uses: ./.github/actions/install-frontend-dependencies
|
||||
- name: Build, lint and test frontend
|
||||
working-directory: grafana-plugin
|
||||
run: yarn lint && yarn test && yarn build
|
||||
|
|
@ -93,11 +71,6 @@ jobs:
|
|||
lint-migrations-backend-mysql-rabbitmq:
|
||||
name: "Lint database migrations"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_HOST: localhost
|
||||
RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
services:
|
||||
rabbit_test:
|
||||
image: rabbitmq:3.12.0
|
||||
|
|
@ -114,21 +87,15 @@ jobs:
|
|||
ports:
|
||||
- 3306:3306
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
- name: Lint migrations
|
||||
working-directory: engine
|
||||
# makemigrations --check = Exit with a non-zero status if model changes are missing migrations
|
||||
# and don't actually write them.
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
python manage.py makemigrations --check
|
||||
python manage.py lintmigrations
|
||||
|
||||
|
|
@ -136,7 +103,8 @@ jobs:
|
|||
name: "Helm Chart Unit Tests"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: v3.8.0
|
||||
|
|
@ -152,10 +120,6 @@ jobs:
|
|||
matrix:
|
||||
rbac_enabled: ["True", "False"]
|
||||
env:
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
DATABASE_HOST: localhost
|
||||
RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }}
|
||||
services:
|
||||
rabbit_test:
|
||||
|
|
@ -173,21 +137,13 @@ jobs:
|
|||
ports:
|
||||
- 3306:3306
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
- name: Unit Test Backend
|
||||
working-directory: engine
|
||||
run: |
|
||||
apt-get update && apt-get install -y netcat-traditional
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
./wait_for_test_mysql_start.sh && pytest -x
|
||||
run: ./wait_for_test_mysql_start.sh && pytest -x
|
||||
|
||||
unit-test-backend-postgresql-rabbitmq:
|
||||
name: "Backend Tests: PostgreSQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})"
|
||||
|
|
@ -197,10 +153,6 @@ jobs:
|
|||
rbac_enabled: ["True", "False"]
|
||||
env:
|
||||
DATABASE_TYPE: postgresql
|
||||
DATABASE_HOST: localhost
|
||||
RABBITMQ_URI: amqp://rabbitmq:rabbitmq@localhost:5672
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }}
|
||||
services:
|
||||
rabbit_test:
|
||||
|
|
@ -224,20 +176,13 @@ jobs:
|
|||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
- name: Unit Test Backend
|
||||
working-directory: engine
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
pytest -x
|
||||
run: pytest -x
|
||||
|
||||
unit-test-backend-sqlite-redis:
|
||||
name: "Backend Tests: SQLite + Redis (RBAC enabled: ${{ matrix.rbac_enabled }})"
|
||||
|
|
@ -249,8 +194,6 @@ jobs:
|
|||
DATABASE_TYPE: sqlite3
|
||||
BROKER_TYPE: redis
|
||||
REDIS_URI: redis://localhost:6379
|
||||
DJANGO_SETTINGS_MODULE: settings.ci-test
|
||||
SLACK_CLIENT_OAUTH_ID: 1
|
||||
ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }}
|
||||
services:
|
||||
redis_test:
|
||||
|
|
@ -263,57 +206,39 @@ jobs:
|
|||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
- name: Unit Test Backend
|
||||
working-directory: engine
|
||||
run: |
|
||||
apt-get update && apt-get install -y netcat-traditional
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
pytest -x
|
||||
run: pytest -x
|
||||
|
||||
unit-test-migrators:
|
||||
name: "Unit tests - Migrators"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: tools/migrators/requirements.txt
|
||||
python-requirements-paths: tools/migrators/requirements.txt
|
||||
- name: Unit Test Migrators
|
||||
working-directory: tools/migrators
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt
|
||||
pytest -x
|
||||
run: pytest -x
|
||||
|
||||
mypy:
|
||||
name: "mypy"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11.4"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
engine/requirements.txt
|
||||
engine/requirements-dev.txt
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-python
|
||||
- name: mypy Static Type Checking
|
||||
working-directory: engine
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
mypy .
|
||||
run: mypy .
|
||||
|
||||
end-to-end-tests:
|
||||
name: Standard e2e tests
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -108,10 +108,10 @@ define run_ui_docker_command
|
|||
$(call run_docker_compose_command,run --rm oncall_ui sh -c '$(1)')
|
||||
endef
|
||||
|
||||
# always use settings.ci-test django settings file when running the tests
|
||||
# always use settings.ci_test django settings file when running the tests
|
||||
# if we use settings.dev it's very possible that some fail just based on the settings alone
|
||||
define run_backend_tests
|
||||
$(call run_engine_docker_command,pytest --ds=settings.ci-test $(1))
|
||||
$(call run_engine_docker_command,pytest --ds=settings.ci_test $(1))
|
||||
endef
|
||||
|
||||
.PHONY: local/up
|
||||
|
|
|
|||
|
|
@ -83,8 +83,10 @@ from an on-call schedule.
|
|||
* `Notify all users from a team` - send a notification to all users in a team.
|
||||
* `Resolve incident automatically` - resolve the alert group right now with status
|
||||
`Resolved automatically`.
|
||||
* `Notify whole slack channel` - send a notification to the users in the slack channel.
|
||||
* `Notify Slack User Group` - send a notification to each member of a slack user group.
|
||||
* `Notify whole slack channel` - send a notification to the users in the slack channel. These users will be notified
|
||||
via the method configured in their user profile.
|
||||
* `Notify Slack User Group` - send a notification to each member of a slack user group. These users will be notified
|
||||
via the method configured in their user profile.
|
||||
* `Trigger outgoing webhook` - trigger an [outgoing webhook].
|
||||
* `Notify users one by one (round robin)` - each notification will be sent to a group of
|
||||
users one by one, in sequential order in [round robin fashion](https://en.wikipedia.org/wiki/Round-robin_item_allocation).
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ def notify_all_task(alert_group_pk, escalation_policy_snapshot_order=None):
|
|||
|
||||
escalation_snapshot = alert_group.escalation_snapshot
|
||||
try:
|
||||
escalation_policy_snapshot = escalation_snapshot.escalation_policies_snapshots[escalation_policy_snapshot_order]
|
||||
# escalation_policy_snapshot_order refers to order as defined in the policy,
|
||||
# which is unique but not necessarily sequential and may not start from zero
|
||||
escalation_policy_snapshot = [
|
||||
policy
|
||||
for policy in escalation_snapshot.escalation_policies_snapshots
|
||||
if policy.order == escalation_policy_snapshot_order
|
||||
][0]
|
||||
except IndexError:
|
||||
escalation_policy_snapshot = None
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,13 @@ def notify_group_task(alert_group_pk, escalation_policy_snapshot_order=None):
|
|||
|
||||
escalation_snapshot = alert_group.escalation_snapshot
|
||||
try:
|
||||
escalation_policy_snapshot = escalation_snapshot.escalation_policies_snapshots[escalation_policy_snapshot_order]
|
||||
# escalation_policy_snapshot_order refers to order as defined in the policy,
|
||||
# which is unique but not necessarily sequential and may not start from zero
|
||||
escalation_policy_snapshot = [
|
||||
policy
|
||||
for policy in escalation_snapshot.escalation_policies_snapshots
|
||||
if policy.order == escalation_policy_snapshot_order
|
||||
][0]
|
||||
except IndexError:
|
||||
escalation_policy_snapshot = None
|
||||
|
||||
|
|
|
|||
78
engine/apps/alerts/tests/test_notify_all.py
Normal file
78
engine/apps/alerts/tests/test_notify_all.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.alerts.models import EscalationPolicy
|
||||
from apps.alerts.tasks.notify_all import notify_all_task
|
||||
from apps.base.models.user_notification_policy import UserNotificationPolicy
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_all(
|
||||
make_organization,
|
||||
make_slack_team_identity,
|
||||
make_user,
|
||||
make_user_notification_policy,
|
||||
make_escalation_chain,
|
||||
make_escalation_policy,
|
||||
make_channel_filter,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
):
|
||||
organization = make_organization()
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
|
||||
user = make_user(organization=organization)
|
||||
make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
escalation_chain=escalation_chain,
|
||||
notify_in_slack=True,
|
||||
slack_channel_id="slack-channel-id",
|
||||
)
|
||||
# note this is the only escalation step, with order=1
|
||||
notify_all = make_escalation_policy(
|
||||
order=1,
|
||||
escalation_chain=channel_filter.escalation_chain,
|
||||
escalation_policy_step=EscalationPolicy.STEP_FINAL_NOTIFYALL,
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel=alert_receive_channel, channel_filter=channel_filter)
|
||||
# build escalation snapshot
|
||||
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
|
||||
alert_group.save()
|
||||
|
||||
with patch(
|
||||
"apps.slack.models.SlackTeamIdentity.get_users_from_slack_conversation_for_organization"
|
||||
) as mock_get_users:
|
||||
mock_get_users.return_value = [user]
|
||||
with patch("apps.alerts.tasks.notify_all.notify_user_task") as mock_notify_user_task:
|
||||
notify_all_task(alert_group.pk, escalation_policy_snapshot_order=1)
|
||||
|
||||
alert_group.refresh_from_db()
|
||||
|
||||
# check triggered log
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == log_record.TYPE_ESCALATION_TRIGGERED
|
||||
assert log_record.author == user
|
||||
assert log_record.escalation_policy == notify_all
|
||||
assert log_record.escalation_policy_step == EscalationPolicy.STEP_FINAL_NOTIFYALL
|
||||
|
||||
# check user is notified
|
||||
mock_notify_user_task.apply_async.assert_called_once_with(
|
||||
args=(user.pk, alert_group.pk),
|
||||
kwargs={"reason": "notifying everyone in the channel", "prevent_posting_to_thread": True},
|
||||
countdown=0,
|
||||
)
|
||||
|
||||
escalation_snapshot = alert_group.escalation_snapshot
|
||||
assert escalation_snapshot is not None
|
||||
assert escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue == [user]
|
||||
84
engine/apps/alerts/tests/test_notify_group.py
Normal file
84
engine/apps/alerts/tests/test_notify_group.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.alerts.models import EscalationPolicy
|
||||
from apps.alerts.tasks.notify_group import notify_group_task
|
||||
from apps.base.models.user_notification_policy import UserNotificationPolicy
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_group(
|
||||
make_organization,
|
||||
make_slack_team_identity,
|
||||
make_user,
|
||||
make_user_notification_policy,
|
||||
make_escalation_chain,
|
||||
make_escalation_policy,
|
||||
make_channel_filter,
|
||||
make_slack_user_group,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
):
|
||||
organization = make_organization()
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
|
||||
user = make_user(organization=organization)
|
||||
# remove default email escalation policies
|
||||
user.notification_policies.all().delete()
|
||||
make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
|
||||
)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
escalation_chain=escalation_chain,
|
||||
notify_in_slack=True,
|
||||
slack_channel_id="slack-channel-id",
|
||||
)
|
||||
usergroup = make_slack_user_group(slack_team_identity)
|
||||
# note this is the only escalation step, with order=1
|
||||
notify_group = make_escalation_policy(
|
||||
order=1,
|
||||
escalation_chain=channel_filter.escalation_chain,
|
||||
escalation_policy_step=EscalationPolicy.STEP_NOTIFY_GROUP,
|
||||
notify_to_group=usergroup,
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel=alert_receive_channel, channel_filter=channel_filter)
|
||||
# build escalation snapshot
|
||||
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
|
||||
alert_group.save()
|
||||
|
||||
with patch("apps.slack.models.SlackUserGroup.get_users_from_members_for_organization") as mock_get_users:
|
||||
mock_get_users.return_value = [user]
|
||||
with patch("apps.alerts.tasks.notify_group.notify_user_task") as mock_notify_user_task:
|
||||
notify_group_task(alert_group.pk, escalation_policy_snapshot_order=1)
|
||||
|
||||
alert_group.refresh_from_db()
|
||||
|
||||
# check triggered log
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == log_record.TYPE_ESCALATION_TRIGGERED
|
||||
assert log_record.escalation_policy == notify_group
|
||||
assert log_record.escalation_policy_step == EscalationPolicy.STEP_NOTIFY_GROUP
|
||||
assert log_record.step_specific_info == {"usergroup_handle": usergroup.handle}
|
||||
|
||||
# check user is notified
|
||||
mock_notify_user_task.apply_async.assert_called_once_with(
|
||||
args=(user.pk, alert_group.pk),
|
||||
kwargs={
|
||||
"reason": f"Membership in <!subteam^{usergroup.slack_id}> User Group",
|
||||
"prevent_posting_to_thread": True,
|
||||
"important": False,
|
||||
},
|
||||
)
|
||||
|
||||
escalation_snapshot = alert_group.escalation_snapshot
|
||||
assert escalation_snapshot is not None
|
||||
assert escalation_snapshot.escalation_policies_snapshots[0].notify_to_users_queue == [user]
|
||||
|
|
@ -72,3 +72,30 @@ def test_usergroup_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_usergroup(
|
||||
make_slack_team_identity,
|
||||
make_slack_user_group,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
team_identity = make_slack_team_identity()
|
||||
user_group = make_slack_user_group(
|
||||
slack_team_identity=team_identity, name="Test User Group", handle="test-user-group"
|
||||
)
|
||||
|
||||
organization = make_organization(slack_team_identity=team_identity)
|
||||
_, token = make_token_for_organization(organization=organization)
|
||||
user = make_user_for_organization(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user_group-detail", kwargs={"pk": user_group.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
expected_data = {"id": user_group.public_primary_key, "name": "Test User Group", "handle": "test-user-group"}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_data
|
||||
|
|
|
|||
|
|
@ -638,6 +638,7 @@ class AlertGroupView(
|
|||
choices=[display_name for _, display_name in AlertGroup.SILENCE_DELAY_OPTIONS]
|
||||
),
|
||||
},
|
||||
many=True,
|
||||
)
|
||||
)
|
||||
@action(methods=["get"], detail=False)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ from apps.api.permissions import RBACPermission
|
|||
from apps.api.serializers.user_group import UserGroupSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.slack.models import SlackUserGroup
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
|
||||
|
||||
class UserGroupViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
class UserGroupViewSet(
|
||||
PublicPrimaryKeyMixin[SlackUserGroup], mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
serializer_class = UserGroupSerializer
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ app_name = "grafana_plugin"
|
|||
)
|
||||
@patch.object(sys, "exit")
|
||||
@override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME)
|
||||
@override_settings(IS_OPEN_SOURCE=True)
|
||||
@override_settings(SELF_HOSTED_SETTINGS={"GRAFANA_API_URL": None})
|
||||
@pytest.mark.django_db
|
||||
def test_it_crashes_the_app_if_the_env_var_is_not_present_for_oss_installations_and_an_org_does_not_exist(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from apps.mobile_app.backend import MobileAppBackend
|
|||
|
||||
MAX_ALERT_TITLE_LENGTH = 200
|
||||
|
||||
# this is a dirty hack to get around EXTRA_MESSAGING_BACKENDS being set in settings/ci-test.py
|
||||
# this is a dirty hack to get around EXTRA_MESSAGING_BACKENDS being set in settings/ci_test.py
|
||||
# we can't simply change the value because 100s of tests fail as they rely on the value being set to a specific value 🫠
|
||||
# see where this value is used in the unitest.mock.patch calls down below for more context
|
||||
backend = MobileAppBackend(notification_channel_id=5)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import typing
|
|||
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.errors import (
|
||||
SlackAPICantUpdateMessageError,
|
||||
SlackAPIChannelArchivedError,
|
||||
SlackAPIChannelInactiveError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
|
|
@ -56,6 +57,7 @@ class AlertGroupSlackService:
|
|||
raise
|
||||
except (
|
||||
SlackAPIMessageNotFoundError,
|
||||
SlackAPICantUpdateMessageError,
|
||||
SlackAPIChannelInactiveError,
|
||||
SlackAPITokenError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from django.utils.text import Truncator
|
|||
from apps.api.permissions import RBACPermission
|
||||
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER
|
||||
from apps.slack.errors import (
|
||||
SlackAPICantUpdateMessageError,
|
||||
SlackAPIChannelArchivedError,
|
||||
SlackAPIChannelInactiveError,
|
||||
SlackAPIChannelNotFoundError,
|
||||
|
|
@ -43,6 +44,7 @@ logger.setLevel(logging.DEBUG)
|
|||
RESOLUTION_NOTE_EXCEPTIONS = (
|
||||
SlackAPIChannelNotFoundError,
|
||||
SlackAPIMessageNotFoundError,
|
||||
SlackAPICantUpdateMessageError,
|
||||
SlackAPIChannelArchivedError,
|
||||
SlackAPIInvalidAuthError,
|
||||
SlackAPITokenError,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 4.2.11 on 2024-05-24 16:22
|
||||
import logging
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_migration_linter as linter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_up_duplicated_teams(apps, schema_editor):
|
||||
Team = apps.get_model("user_management", "Team")
|
||||
User = apps.get_model("user_management", "User")
|
||||
|
||||
# get (organization_id, team_id) pairs for duplicated teams
|
||||
duplicate_rows = Team.objects.values_list(
|
||||
"organization_id", "team_id"
|
||||
).annotate(count=models.Count("id")).filter(count__gt=1)
|
||||
|
||||
for organization_id, team_id, _ in duplicate_rows:
|
||||
# keep first team
|
||||
first_team = Team.objects.filter(
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
).order_by("id").first()
|
||||
|
||||
# migrate resources associated to duplicated entries
|
||||
duplicated_teams = Team.objects.filter(
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
).exclude(id=first_team.id)
|
||||
|
||||
for team in duplicated_teams:
|
||||
# if there is anything to migrate, do it here
|
||||
team.escalation_chains.update(team=first_team)
|
||||
team.alert_receive_channels.exclude(integration="direct_paging").update(team=first_team)
|
||||
team.custom_on_call_shifts.update(team=first_team)
|
||||
team.oncall_schedules.update(team=first_team)
|
||||
team.webhooks.update(team=first_team)
|
||||
User.objects.filter(organization_id=organization_id, current_team=team).update(current_team=first_team)
|
||||
|
||||
# delete duplicated teams
|
||||
num_deleted, _ = duplicated_teams.delete()
|
||||
logger.info(
|
||||
f"Deleted {num_deleted} duplicate teams for ({organization_id}, {team_id}), "
|
||||
f"keeping team with id: {first_team.id}."
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user_management', '0021_user_google_calendar_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
linter.IgnoreMigration(), # removing duplicated teams is ok, no way to revert this
|
||||
migrations.RunPython(clean_up_duplicated_teams, migrations.RunPython.noop),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='team',
|
||||
unique_together={('organization', 'team_id')},
|
||||
),
|
||||
]
|
||||
|
|
@ -52,7 +52,8 @@ class TeamManager(models.Manager["Team"]):
|
|||
for team in grafana_teams.values()
|
||||
if team["id"] not in existing_team_ids
|
||||
)
|
||||
organization.teams.bulk_create(teams_to_create, batch_size=5000)
|
||||
# create entries, ignore failed insertions if team_id already exists in the organization
|
||||
organization.teams.bulk_create(teams_to_create, batch_size=5000, ignore_conflicts=True)
|
||||
|
||||
# create missing direct paging integrations
|
||||
AlertReceiveChannel.objects.create_missing_direct_paging_integrations(organization)
|
||||
|
|
@ -123,3 +124,6 @@ class Team(models.Model):
|
|||
# If is_sharing_resources_to_all is False only team members and admins can access it and it's resources
|
||||
# if it's True every oncall organization user can access it
|
||||
is_sharing_resources_to_all = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("organization", "team_id")
|
||||
|
|
|
|||
14
engine/apps/user_management/tests/test_team.py
Normal file
14
engine/apps/user_management/tests/test_team.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import pytest
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_uniqueness(make_organization):
|
||||
organization = make_organization()
|
||||
|
||||
# Create a team
|
||||
organization.teams.create(name="Team 1", team_id=1)
|
||||
|
||||
# Try to create another team with the same team_id
|
||||
with pytest.raises(IntegrityError):
|
||||
organization.teams.create(name="Team 2", team_id=1)
|
||||
|
|
@ -53,6 +53,12 @@ def generate_public_primary_key_for_webhook():
|
|||
return new_public_primary_key
|
||||
|
||||
|
||||
class WebhookSession(requests.Session):
|
||||
def send(self, request, **kwargs):
|
||||
parse_url(request.url) # validate URL on every redirect
|
||||
return super().send(request, **kwargs)
|
||||
|
||||
|
||||
class WebhookQueryset(models.QuerySet):
|
||||
def delete(self):
|
||||
self.update(deleted_at=timezone.now(), name=F("name") + "_deleted_" + F("public_primary_key"))
|
||||
|
|
@ -276,21 +282,15 @@ class Webhook(models.Model):
|
|||
raise InvalidWebhookTrigger(e.fallback_message)
|
||||
|
||||
def make_request(self, url, request_kwargs):
|
||||
if self.http_method == "GET":
|
||||
r = requests.get(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
elif self.http_method == "POST":
|
||||
r = requests.post(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
elif self.http_method == "PUT":
|
||||
r = requests.put(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
elif self.http_method == "DELETE":
|
||||
r = requests.delete(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
elif self.http_method == "OPTIONS":
|
||||
r = requests.options(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
elif self.http_method == "PATCH":
|
||||
r = requests.patch(url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs)
|
||||
else:
|
||||
if self.http_method not in ("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"):
|
||||
raise ValueError(f"Unsupported http method: {self.http_method}")
|
||||
return r
|
||||
|
||||
with WebhookSession() as session:
|
||||
response = session.request(
|
||||
self.http_method, url, timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, **request_kwargs
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# Insight logs
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, Escala
|
|||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
from apps.webhooks.models import Webhook
|
||||
from apps.webhooks.models.webhook import WebhookSession
|
||||
from apps.webhooks.tasks import execute_webhook, send_webhook_event
|
||||
from apps.webhooks.tasks.trigger_webhook import NOT_FROM_SELECTED_INTEGRATION
|
||||
from settings.base import WEBHOOK_RESPONSE_LIMIT
|
||||
|
|
@ -141,10 +142,10 @@ def test_execute_webhook_integration_filter_not_matching(
|
|||
)
|
||||
webhook.filtered_integrations.add(other_alert_receive_channel)
|
||||
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
assert not mock_request.called
|
||||
# no response is created for the webhook
|
||||
assert webhook.responses.count() == 0
|
||||
# check log should exist
|
||||
|
|
@ -166,10 +167,10 @@ def test_execute_webhook_integration_filter_matching(
|
|||
)
|
||||
webhook.filtered_integrations.add(alert_receive_channel)
|
||||
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
assert not mock_request.called
|
||||
# no response is created for the webhook
|
||||
assert webhook.responses.count() == 0
|
||||
# check log should exist
|
||||
|
|
@ -235,10 +236,13 @@ def test_execute_webhook_ok(
|
|||
httpretty.register_uri(httpretty.POST, templated_url, responses=[mock_response])
|
||||
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8"):
|
||||
with patch("apps.webhooks.models.webhook.requests", wraps=requests) as mock_requests:
|
||||
with patch(
|
||||
"apps.webhooks.models.webhook.WebhookSession.request", wraps=WebhookSession().request
|
||||
) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
mock_requests.post.assert_called_once_with(
|
||||
mock_request.assert_called_once_with(
|
||||
"POST",
|
||||
templated_url,
|
||||
timeout=TIMEOUT,
|
||||
headers={"some-header": alert_group.public_primary_key},
|
||||
|
|
@ -310,11 +314,10 @@ def test_execute_webhook_via_escalation_ok(
|
|||
mock_response = MockResponse()
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, escalation_policy.pk)
|
||||
|
||||
assert mock_requests.post.called
|
||||
assert mock_request.called
|
||||
# check log record
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED
|
||||
|
|
@ -377,11 +380,10 @@ def test_execute_webhook_ok_forward_all(
|
|||
mock_response = MockResponse()
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ACKNOWLEDGE)
|
||||
|
||||
assert mock_requests.post.called
|
||||
assert mock_request.called
|
||||
expected_data = {
|
||||
"event": {
|
||||
"type": "acknowledge",
|
||||
|
|
@ -423,12 +425,13 @@ def test_execute_webhook_ok_forward_all(
|
|||
"alert_group_resolved_by": None,
|
||||
}
|
||||
expected_call = call(
|
||||
"POST",
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
timeout=TIMEOUT,
|
||||
headers={},
|
||||
json=expected_data,
|
||||
)
|
||||
assert mock_requests.post.call_args == expected_call
|
||||
assert mock_request.call_args == expected_call
|
||||
# check logs
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.trigger_type == Webhook.TRIGGER_ACKNOWLEDGE
|
||||
|
|
@ -485,11 +488,10 @@ def test_execute_webhook_ok_forward_all_resolved(
|
|||
mock_response = MockResponse()
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_RESOLVE)
|
||||
|
||||
assert mock_requests.post.called
|
||||
assert mock_request.called
|
||||
expected_data = {
|
||||
"event": {
|
||||
"type": "resolve",
|
||||
|
|
@ -535,12 +537,13 @@ def test_execute_webhook_ok_forward_all_resolved(
|
|||
},
|
||||
}
|
||||
expected_call = call(
|
||||
"POST",
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
timeout=TIMEOUT,
|
||||
headers={},
|
||||
json=expected_data,
|
||||
)
|
||||
assert mock_requests.post.call_args == expected_call
|
||||
assert mock_request.call_args == expected_call
|
||||
# check logs
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.trigger_type == Webhook.TRIGGER_RESOLVE
|
||||
|
|
@ -610,19 +613,19 @@ def test_execute_webhook_using_responses_data(
|
|||
mock_response = MockResponse()
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
assert mock_requests.post.called
|
||||
assert mock_request.called
|
||||
expected_data = {"value": "updated"}
|
||||
expected_call = call(
|
||||
"POST",
|
||||
"https://something/third-party-id/",
|
||||
timeout=TIMEOUT,
|
||||
headers={},
|
||||
json=expected_data,
|
||||
)
|
||||
assert mock_requests.post.call_args == expected_call
|
||||
assert mock_request.call_args == expected_call
|
||||
# check logs
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.status_code == 200
|
||||
|
|
@ -646,10 +649,10 @@ def test_execute_webhook_trigger_false(
|
|||
trigger_template="{{ integration_id == 'the-integration' }}",
|
||||
)
|
||||
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
assert not mock_request.called
|
||||
# no response is created for the webhook
|
||||
assert webhook.responses.count() == 0
|
||||
# check log should exist
|
||||
|
|
@ -709,10 +712,10 @@ def test_execute_webhook_errors(
|
|||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
# make it a valid URL when resolving name
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
assert not mock_requests.post.called
|
||||
assert not mock_request.called
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.status_code is None
|
||||
assert log.content is None
|
||||
|
|
@ -755,17 +758,17 @@ def test_response_content_limit(
|
|||
mock_response = MockResponse(content="A" * content_length)
|
||||
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:
|
||||
mock_gethostbyname.return_value = "8.8.8.8"
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
mock_requests.post.return_value = mock_response
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=mock_response) as mock_request:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None)
|
||||
|
||||
assert mock_requests.post.called
|
||||
assert mock_request.called
|
||||
expected_call = call(
|
||||
"POST",
|
||||
"https://test/",
|
||||
timeout=TIMEOUT,
|
||||
headers={},
|
||||
)
|
||||
assert mock_requests.post.call_args == expected_call
|
||||
assert mock_request.call_args == expected_call
|
||||
# check logs
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.status_code == 200
|
||||
|
|
@ -774,13 +777,13 @@ def test_response_content_limit(
|
|||
|
||||
|
||||
@patch("apps.webhooks.tasks.trigger_webhook.execute_webhook", wraps=execute_webhook)
|
||||
@patch("apps.webhooks.models.webhook.requests")
|
||||
@patch("apps.webhooks.models.webhook.WebhookSession.request")
|
||||
@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8")
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("exception", [requests.exceptions.ConnectTimeout, requests.exceptions.ReadTimeout])
|
||||
def test_manually_retried_exceptions(
|
||||
_mock_gethostbyname,
|
||||
mock_requests,
|
||||
mock_request,
|
||||
spy_execute_webhook,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
|
|
@ -789,7 +792,7 @@ def test_manually_retried_exceptions(
|
|||
make_custom_webhook,
|
||||
exception,
|
||||
):
|
||||
mock_requests.post.side_effect = exception("foo bar")
|
||||
mock_request.side_effect = exception("foo bar")
|
||||
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization)
|
||||
|
|
@ -810,12 +813,12 @@ def test_manually_retried_exceptions(
|
|||
# should retry
|
||||
execute_webhook(*execute_webhook_args)
|
||||
|
||||
mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={})
|
||||
mock_request.assert_called_once_with("POST", "https://test/", timeout=TIMEOUT, headers={})
|
||||
spy_execute_webhook.apply_async.assert_called_once_with(
|
||||
execute_webhook_args, kwargs={"trigger_type": None, "manual_retry_num": 1}, countdown=10
|
||||
)
|
||||
|
||||
mock_requests.reset_mock()
|
||||
mock_request.reset_mock()
|
||||
spy_execute_webhook.reset_mock()
|
||||
|
||||
# should stop retrying after 3 attempts without raising issue
|
||||
|
|
@ -824,16 +827,16 @@ def test_manually_retried_exceptions(
|
|||
except Exception:
|
||||
pytest.fail()
|
||||
|
||||
mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={})
|
||||
mock_request.assert_called_once_with("POST", "https://test/", timeout=TIMEOUT, headers={})
|
||||
spy_execute_webhook.apply_async.assert_not_called()
|
||||
|
||||
|
||||
@patch("apps.webhooks.models.webhook.requests.post", return_value=MockResponse())
|
||||
@patch("apps.webhooks.models.webhook.WebhookSession.request", return_value=MockResponse())
|
||||
@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8")
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_integration_config(
|
||||
_,
|
||||
mock_requests_post,
|
||||
mock_request,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
|
|
@ -879,14 +882,15 @@ def test_execute_webhook_integration_config(
|
|||
) as mock_on_webhook_response_created:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED)
|
||||
|
||||
assert mock_requests_post.called
|
||||
assert mock_request.called
|
||||
|
||||
# check external ID
|
||||
assert mock_requests_post.call_args[0][0] == "https://something/test123"
|
||||
assert mock_requests_post.call_args[1]["json"]["external_id"] == "test123"
|
||||
assert mock_request.call_args[0][0] == "POST"
|
||||
assert mock_request.call_args[0][1] == "https://something/test123"
|
||||
assert mock_request.call_args[1]["json"]["external_id"] == "test123"
|
||||
|
||||
# check additional webhook data
|
||||
assert mock_requests_post.call_args[1]["json"]["additional_field"] == "additional_value"
|
||||
assert mock_request.call_args[1]["json"]["additional_field"] == "additional_value"
|
||||
mock_additional_webhook_data.assert_called_once_with(source_alert_receive_channel)
|
||||
|
||||
# check on_webhook_response_created is called
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
import httpretty
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
|
@ -225,13 +226,11 @@ def test_check_trigger_template_ok(make_organization, make_custom_webhook):
|
|||
def test_make_request(make_organization, make_custom_webhook):
|
||||
organization = make_organization()
|
||||
|
||||
with patch("apps.webhooks.models.webhook.requests") as mock_requests:
|
||||
with patch("apps.webhooks.models.webhook.WebhookSession.request") as mock_request:
|
||||
for method in ("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"):
|
||||
webhook = make_custom_webhook(organization=organization, http_method=method)
|
||||
webhook.make_request("url", {"foo": "bar"})
|
||||
expected_call = getattr(mock_requests, method.lower())
|
||||
assert expected_call.called
|
||||
assert expected_call.call_args == call("url", timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, foo="bar")
|
||||
assert mock_request.call_args == call(method, "url", timeout=settings.OUTGOING_WEBHOOK_TIMEOUT, foo="bar")
|
||||
|
||||
# invalid
|
||||
webhook = make_custom_webhook(organization=organization, http_method="NOT")
|
||||
|
|
@ -239,6 +238,20 @@ def test_make_request(make_organization, make_custom_webhook):
|
|||
webhook.make_request("url", {"foo": "bar"})
|
||||
|
||||
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
@pytest.mark.django_db
|
||||
def test_make_request_bad_redirect(make_organization, make_custom_webhook):
|
||||
organization = make_organization()
|
||||
webhook = make_custom_webhook(organization=organization, http_method="POST")
|
||||
|
||||
url = "http://example.com"
|
||||
response = httpretty.Response(body="Redirect", status=302, location="127.0.0.1")
|
||||
httpretty.register_uri(httpretty.POST, url, responses=[response])
|
||||
|
||||
with pytest.raises(InvalidWebhookUrl):
|
||||
webhook.make_request(url, {})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escaping_payload_with_double_quotes(make_organization, make_custom_webhook):
|
||||
organization = make_organization()
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ prometheus_client==0.16.0
|
|||
psutil==5.9.4
|
||||
psycopg2==2.9.3
|
||||
pymdown-extensions==10.0
|
||||
PyMySQL==1.1.0
|
||||
PyMySQL==1.1.1
|
||||
python-telegram-bot==13.13
|
||||
recurring-ical-events==2.1.0
|
||||
redis==5.0.1
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ pyjwt==2.8.0
|
|||
# twilio
|
||||
pymdown-extensions==10.0
|
||||
# via -r requirements.in
|
||||
pymysql==1.1.0
|
||||
pymysql==1.1.1
|
||||
# via -r requirements.in
|
||||
pyopenssl==23.2.0
|
||||
# via django-sns-view
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@
|
|||
--always-gray: #ccccdc;
|
||||
--title-marginBottom: 16px;
|
||||
--opacity: 0.5;
|
||||
--tag-danger: #e02f44;
|
||||
--tag-warning: #c69b06;
|
||||
--tag-primary: #299c46;
|
||||
--tag-secondary: #464c54;
|
||||
--tag-secondary-transparent: rgba(204, 204, 220, 0.07);
|
||||
--tag-border-link: rgba(56, 113, 220, 0.2);
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
|
@ -32,25 +26,12 @@
|
|||
--oncall-icon-stroke-color: #fff;
|
||||
--hover-selected: #f4f5f5;
|
||||
--background-canvas: #f4f5f5;
|
||||
--background-primary: #fff;
|
||||
--background-secondary: #f4f5f5;
|
||||
--border-medium-color: rgba(36, 41, 46, 0.3);
|
||||
--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-z3: 0 13px 20px 1px rgba(24, 26, 27, 0.18);
|
||||
--tag-background-primary: rgba(50, 116, 217, 0.15);
|
||||
--tag-border-primary: rgb(136, 174, 233);
|
||||
--tag-text-primary: rgb(26, 71, 139);
|
||||
--tag-background-warning: rgba(255, 120, 10, 0.15);
|
||||
--tag-border-warning: rgb(255, 176, 112);
|
||||
--tag-text-warning: rgb(163, 73, 0);
|
||||
--tag-background-success: rgba(86, 166, 75, 0.15);
|
||||
--tag-border-success: rgb(148, 203, 140);
|
||||
--tag-text-success: rgb(50, 96, 43);
|
||||
--tag-background-danger: rgba(224, 47, 68, 0.15);
|
||||
--tag-border-danger: rgb(237, 136, 148);
|
||||
--tag-text-danger: rgb(147, 22, 37);
|
||||
--button-background: rgba(36, 41, 46, 0.08);
|
||||
--button-hover-background: rgba(36, 41, 46, 0.15);
|
||||
--box-background: rgba(244, 245, 245);
|
||||
|
|
@ -79,25 +60,12 @@
|
|||
--hover-selected-hardcoded: #34363d;
|
||||
--oncall-icon-stroke-color: #181b1f;
|
||||
--background-canvas: #111217;
|
||||
--background-primary: #181b1f;
|
||||
--background-secondary: #22252b;
|
||||
--border-medium-color: rgba(204, 204, 220, 0.15);
|
||||
--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-z3: 0 8px 24px rgb(1, 4, 9);
|
||||
--tag-background-primary: rgba(87, 148, 242, 0.15);
|
||||
--tag-border-primary: rgb(13, 72, 163);
|
||||
--tag-text-primary: rgb(158, 193, 247);
|
||||
--tag-background-warning: rgba(255, 152, 48, 0.15);
|
||||
--tag-border-warning: rgb(150, 75, 0);
|
||||
--tag-text-warning: rgb(255, 190, 124);
|
||||
--tag-background-success: rgba(115, 191, 105, 0.15);
|
||||
--tag-border-success: rgb(49, 100, 43);
|
||||
--tag-text-success: rgb(165, 214, 159);
|
||||
--tag-background-danger: rgba(242, 73, 92, 0.15);
|
||||
--tag-border-danger: rgb(151, 11, 27);
|
||||
--tag-text-danger: rgb(247, 144, 156);
|
||||
--box-background: rgba(10, 10, 10, 0.4);
|
||||
--working-hours-shades-color: rgba(17, 18, 23, 0.15);
|
||||
--working-hours-shades-color-light: rgba(17, 18, 23, 0.1);
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export const CardButton: FC<CardButtonProps> = (props) => {
|
|||
className={cx(styles.root, { [styles.rootSelected]: selected })}
|
||||
data-testid="test__cardButton"
|
||||
>
|
||||
<div className={cx(styles.icon)}>{icon}</div>
|
||||
<div className={cx(styles.meta)}>
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<div className={styles.meta}>
|
||||
<VerticalGroup spacing="xs">
|
||||
<Text type="secondary">{description}</Text>
|
||||
<Text.Title level={1}>{title}</Text.Title>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const Collapse: FC<CollapseProps> = (props) => {
|
|||
data-testid="test__toggle"
|
||||
>
|
||||
<Icon name={'angle-right'} size="xl" className={cx(styles.icon, { [bem(styles.icon, 'rotated')]: isOpen })} />
|
||||
<div className={cx(styles.label)}> {label}</div>
|
||||
<div className={styles.label}> {label}</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className={cx(styles.content, contentClassName)} data-testid="test__children">
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const GList = <T extends WithId>(props: GListProps<T>) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)}>
|
||||
<div className={styles.root}>
|
||||
{items ? (
|
||||
items.map((item) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
key: 'check',
|
||||
title: (
|
||||
<Checkbox
|
||||
className={cx(styles.checkbox)}
|
||||
className={styles.checkbox}
|
||||
onChange={handleMasterCheckboxChange}
|
||||
value={data?.length > 0 && rowSelection.selectedRowKeys.length === data?.length}
|
||||
/>
|
||||
|
|
@ -124,7 +124,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
render: (item: any) => {
|
||||
return (
|
||||
<Checkbox
|
||||
className={cx(styles.checkbox)}
|
||||
className={styles.checkbox}
|
||||
value={rowSelection.selectedRowKeys.includes(item[rowKey as string])}
|
||||
onChange={getCheckboxClickHandler(item[rowKey as string])}
|
||||
/>
|
||||
|
|
@ -136,7 +136,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
}, [rowSelection, columnsProp, data]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)} data-testid="test__gTable">
|
||||
<div className={styles.root} data-testid="test__gTable">
|
||||
<Table<RT>
|
||||
expandable={expandable}
|
||||
rowKey={rowKey}
|
||||
|
|
@ -148,7 +148,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
{...restProps}
|
||||
/>
|
||||
{pagination && (
|
||||
<div className={cx(styles.pagination)}>
|
||||
<div className={styles.pagination}>
|
||||
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={numberOfPages} onNavigate={onNavigate} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ const IntegrationCollapsibleTreeItem: React.FC<{
|
|||
return (
|
||||
<div className={cx(styles.group, { [bem(styles.group, 'hidden')]: item.isHidden }, 'group')} data-emotion="group">
|
||||
<div
|
||||
className={cx(styles.icon)}
|
||||
className={styles.icon}
|
||||
style={{
|
||||
transform: `translateY(${item.startingElemPosition || 0})`,
|
||||
}}
|
||||
|
|
@ -139,7 +139,7 @@ const IntegrationCollapsibleTreeItem: React.FC<{
|
|||
function renderIcon() {
|
||||
if (item.isTextIcon && elementPosition) {
|
||||
return (
|
||||
<Text type="primary" customTag="h6" className={cx(styles.numberIcon)}>
|
||||
<Text type="primary" customTag="h6" className={styles.numberIcon}>
|
||||
{elementPosition}
|
||||
</Text>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const IntegrationInputField: React.FC<IntegrationInputFieldProps> = ({
|
|||
|
||||
return (
|
||||
<div className={cx(styles.root, { [className]: !!className })}>
|
||||
<div className={cx(styles.inputContainer)}>{renderInputField()}</div>
|
||||
<div className={styles.inputContainer}>{renderInputField()}</div>
|
||||
|
||||
<div className={cx(styles.icons, iconsClassName)}>
|
||||
<HorizontalGroup spacing={'xs'}>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const IntegrationBlock: React.FC<IntegrationBlockProps> = ({
|
|||
</Block>
|
||||
)}
|
||||
{content && (
|
||||
<div className={cx(styles.integrationBlockContent)} onClick={toggle}>
|
||||
<div className={styles.integrationBlockContent} onClick={toggle}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
|
|
@ -12,8 +12,8 @@ export const IntegrationBlockItem: React.FC<IntegrationBlockItemProps> = (props)
|
|||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.parent)} data-testid="integration-block-item">
|
||||
<div className={cx(styles.content)}>{props.children}</div>
|
||||
<div className={styles.parent} data-testid="integration-block-item">
|
||||
<div className={styles.content}>{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, InlineLabel, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||
|
||||
|
|
@ -41,11 +41,11 @@ export const IntegrationTemplateBlock: React.FC<IntegrationTemplateBlockProps> =
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={styles.container}>
|
||||
<InlineLabel width={20} {...inlineLabelProps}>
|
||||
{label}
|
||||
</InlineLabel>
|
||||
<div className={cx(styles.item)}>
|
||||
<div className={styles.item}>
|
||||
{renderInput()}
|
||||
{isTemplateEditable && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
|
|
@ -33,9 +33,9 @@ export const NewScheduleSelector: FC<NewScheduleSelectorProps> = ({ onHide, onCr
|
|||
|
||||
return (
|
||||
<Drawer scrollableContent title="Create new schedule" onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className={cx(styles.content)}>
|
||||
<div className={styles.content}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<Block bordered withBackground className={cx(styles.block)}>
|
||||
<Block bordered withBackground className={styles.block}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="md">
|
||||
<Icon name="calendar-alt" size="xl" />
|
||||
|
|
@ -53,7 +53,7 @@ export const NewScheduleSelector: FC<NewScheduleSelectorProps> = ({ onHide, onCr
|
|||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</Block>
|
||||
<Block bordered withBackground className={cx(styles.block)}>
|
||||
<Block bordered withBackground className={styles.block}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="md">
|
||||
<Icon name="download-alt" size="xl" />
|
||||
|
|
@ -69,7 +69,7 @@ export const NewScheduleSelector: FC<NewScheduleSelectorProps> = ({ onHide, onCr
|
|||
</Button>
|
||||
</HorizontalGroup>
|
||||
</Block>
|
||||
<Block bordered withBackground className={cx(styles.block)}>
|
||||
<Block bordered withBackground className={styles.block}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="md">
|
||||
<Icon name="cog" size="xl" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ 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') {
|
||||
} 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 } };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
|
||||
|
|
@ -52,9 +52,9 @@ export const PageErrorHandlingWrapper = function ({
|
|||
const { wrongTeamNoPermissions } = errorData;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.notFound)}>
|
||||
<div className={styles.notFound}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1} className={cx(styles.errorCode)}>
|
||||
<Text.Title level={1} className={styles.errorCode}>
|
||||
403
|
||||
</Text.Title>
|
||||
{wrongTeamNoPermissions && (
|
||||
|
|
|
|||
|
|
@ -383,13 +383,12 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
|
||||
return (
|
||||
<WithPermissionControlTooltip key="notify_to_group" userAction={UserActions.EscalationChainsWrite}>
|
||||
<GSelect<UserGroup[]>
|
||||
<GSelect<UserGroup>
|
||||
allowClear
|
||||
disabled={isDisabled}
|
||||
items={userGroupStore.items}
|
||||
fetchItemsFn={userGroupStore.updateItems}
|
||||
fetchItemFn={() => undefined}
|
||||
// TODO: fetchItemFn
|
||||
fetchItemFn={userGroupStore.fetchItemById}
|
||||
getSearchResult={userGroupStore.getSearchResult}
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,20 @@ import React, { FC, ReactNode } from 'react';
|
|||
|
||||
interface RenderConditionallyProps {
|
||||
shouldRender?: boolean;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
render?: () => ReactNode;
|
||||
backupChildren?: ReactNode;
|
||||
}
|
||||
|
||||
export const RenderConditionally: FC<RenderConditionallyProps> = ({ shouldRender, children, backupChildren = null }) =>
|
||||
shouldRender ? <>{children}</> : <>{backupChildren}</>;
|
||||
export const RenderConditionally: FC<RenderConditionallyProps> = ({
|
||||
shouldRender,
|
||||
children,
|
||||
render,
|
||||
backupChildren = null,
|
||||
}) => {
|
||||
if (render) {
|
||||
return shouldRender ? <>{render()}</> : <>{backupChildren}</>;
|
||||
}
|
||||
|
||||
return shouldRender ? <>{children}</> : <>{backupChildren}</>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface ScheduleBorderedAvatarProps {
|
||||
|
|
@ -20,13 +20,13 @@ export const ScheduleBorderedAvatar = function ({
|
|||
}: ScheduleBorderedAvatarProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return <div className={cx(styles.root)}>{renderSVG()}</div>;
|
||||
return <div className={styles.root}>{renderSVG()}</div>;
|
||||
|
||||
function renderAvatarIcon() {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.avatar)}>{renderAvatar()}</div>
|
||||
<div className={cx(styles.icon)}>{renderIcon()}</div>
|
||||
<div className={styles.avatar}>{renderAvatar()}</div>
|
||||
<div className={styles.icon}>{renderIcon()}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { InlineSwitch, useStyles2 } from '@grafana/ui';
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export const ScheduleFilters = (props: SchedulesFiltersProps) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)}>
|
||||
<div className={styles.root}>
|
||||
<InlineSwitch
|
||||
showLabel
|
||||
label="Highlight my shifts"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const ScheduleQuality: FC<ScheduleQualityProps> = observer(({ schedule })
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.root)} data-testid="schedule-quality">
|
||||
<div className={styles.root} data-testid="schedule-quality">
|
||||
{relatedScheduleEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
|
||||
<TooltipBadge
|
||||
borderType="success"
|
||||
|
|
@ -88,7 +88,7 @@ export const ScheduleQuality: FC<ScheduleQualityProps> = observer(({ schedule })
|
|||
content={<ScheduleQualityDetails quality={quality} getScheduleQualityString={getScheduleQualityString} />}
|
||||
>
|
||||
<div className={cx(utils.cursorDefault)}>
|
||||
<Tag className={cx(styles.tag)} color={getTagSeverity()}>
|
||||
<Tag className={styles.tag} color={getTagSeverity()}>
|
||||
Quality: <strong>{getScheduleQualityString(quality.total_score)}</strong>
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
const warningComments = comments.filter((c) => c.type === 'warning');
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)} data-testid="schedule-quality-details">
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={styles.root} data-testid="schedule-quality-details">
|
||||
<div className={styles.container}>
|
||||
<div className={cx(styles.container, bem(styles.container, 'withLateralPadding'))}>
|
||||
<Text type="secondary" className={cx(styles.header)}>
|
||||
<Text type="secondary" className={styles.header}>
|
||||
Schedule quality:{' '}
|
||||
<Text type="primary" className={cx(styles.headerSubText)}>
|
||||
<Text type="primary" className={styles.headerSubText}>
|
||||
{getScheduleQualityString(score)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
|
@ -53,10 +53,10 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
<>
|
||||
{/* Show Info comments */}
|
||||
{infoComments?.length > 0 && (
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={cx(styles.row)}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.row}>
|
||||
<Icon name="info-circle" />
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={styles.container}>
|
||||
{infoComments.map((comment, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{comment.text}
|
||||
|
|
@ -69,10 +69,10 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
|
||||
{/* Show Warning comments afterwards */}
|
||||
{warningComments?.length > 0 && (
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={cx(styles.row)}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.row}>
|
||||
<Icon name="calendar-alt" />
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={styles.container}>
|
||||
<Text type="secondary">Rotation structure issues</Text>
|
||||
{warningComments.map((comment, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
|
|
@ -87,13 +87,13 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
)}
|
||||
|
||||
{overloaded_users?.length > 0 && (
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={cx(styles.row)}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.row}>
|
||||
<Icon name="users-alt" />
|
||||
<div className={cx(styles.container)}>
|
||||
<div className={styles.container}>
|
||||
<Text type="secondary">Overloaded users</Text>
|
||||
{overloaded_users.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx(styles.username)} key={index}>
|
||||
<Text type="primary" className={styles.username} key={index}>
|
||||
{overloadedUser.username} (+{overloadedUser.score}% avg)
|
||||
</Text>
|
||||
))}
|
||||
|
|
@ -115,7 +115,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Icon name="calculator-alt" />
|
||||
<Text type="secondary" className={cx(styles.metholodogy)}>
|
||||
<Text type="secondary" className={styles.metholodogy}>
|
||||
Calculation methodology
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
|
|
@ -126,7 +126,7 @@ export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ qualit
|
|||
/>
|
||||
</HorizontalGroup>
|
||||
{expanded && (
|
||||
<Text type="primary" className={cx(styles.text)}>
|
||||
<Text type="primary" className={styles.text}>
|
||||
The next 52 weeks (~1 year) are taken into account when generating the quality report. Refer to the{' '}
|
||||
<a
|
||||
href={'https://grafana.com/docs/oncall/latest/on-call-schedules/web-schedule/#schedule-quality-report'}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const ScheduleQualityProgressBar: React.FC<ProgressBarProps> = ({ classNa
|
|||
const classList = [styles.bar, className || ''];
|
||||
|
||||
return (
|
||||
<div className={cx(styles.wrapper)}>
|
||||
<div className={styles.wrapper}>
|
||||
{!numTotalSteps && <div className={classList.join(' ')} style={{ width: `${completed}%` }} />}
|
||||
{renderSteps(numTotalSteps, completed)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const SourceCode: FC<SourceCodeProps> = ({
|
|||
{showClipboardIconOnly ? (
|
||||
<IconButton
|
||||
aria-label="Copy"
|
||||
className={cx(styles.copyIcon)}
|
||||
className={styles.copyIcon}
|
||||
size={'lg'}
|
||||
name="copy"
|
||||
data-emotion="copyIcon"
|
||||
|
|
@ -62,7 +62,7 @@ export const SourceCode: FC<SourceCodeProps> = ({
|
|||
/>
|
||||
) : (
|
||||
<Button
|
||||
className={cx(styles.copyIcon)}
|
||||
className={styles.copyIcon}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
icon="copy"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const GTable: FC<Props> = (props) => {
|
|||
</div>
|
||||
);
|
||||
},
|
||||
expandedRowClassName: (_record, index) => (index % 2 === 0 ? cx(styles.rowEven) : ''),
|
||||
expandedRowClassName: (_record, index) => (index % 2 === 0 ? styles.rowEven : ''),
|
||||
}
|
||||
: null;
|
||||
}, [expandable]);
|
||||
|
|
@ -61,11 +61,11 @@ export const GTable: FC<Props> = (props) => {
|
|||
columns={columns}
|
||||
data={data}
|
||||
expandable={expandableFn}
|
||||
rowClassName={(_record, index) => (index % 2 === 0 ? cx(styles.rowEven) : '')}
|
||||
rowClassName={(_record, index) => (index % 2 === 0 ? styles.rowEven : '')}
|
||||
{...restProps}
|
||||
/>
|
||||
{pagination && (
|
||||
<div className={cx(styles.pagination)}>
|
||||
<div className={styles.pagination}>
|
||||
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={numberOfPages} onNavigate={onNavigate} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { FC } from 'react';
|
|||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { bem, getLabelCss } from 'styles/utils.styles';
|
||||
|
||||
interface TagProps {
|
||||
|
|
@ -30,6 +30,7 @@ export enum TagColor {
|
|||
|
||||
export const Tag: FC<TagProps> = (props) => {
|
||||
const { color, children, className, onClick, size = 'medium' } = props;
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export const Tag: FC<TagProps> = (props) => {
|
|||
styles[color]
|
||||
: css`
|
||||
background-color: ${color};
|
||||
color: text;
|
||||
color: ${theme.colors.primary.contrastText};
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
|
|||
placement={placement || 'bottom-start'}
|
||||
interactive
|
||||
content={
|
||||
<div className={cx(styles.tooltip)}>
|
||||
<div className={styles.tooltip}>
|
||||
<VerticalGroup spacing="xs">
|
||||
<Text type="primary">{tooltipTitle}</Text>
|
||||
{tooltipContent && <Text type="secondary">{tooltipContent}</Text>}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Block className={cx(styles.root)} bordered>
|
||||
<div className={cx(styles.title)}>{title}</div>
|
||||
<div className={cx(styles.steps)}>
|
||||
<div className={cx(styles.step)}>
|
||||
<Block className={styles.root} bordered>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<PluginLink query={{ page: 'integrations' }}>
|
||||
<div className={cx(styles.icon, { [bem(styles.icon, 'active')]: step === TutorialStep.Integrations })}>
|
||||
<img src={integrationsIcon} />
|
||||
|
|
@ -38,7 +38,7 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
<Text type="secondary">Add integration with a monitoring system</Text>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={cx(styles.step)}>
|
||||
<div className={styles.step}>
|
||||
<PluginLink query={{ page: 'escalations' }}>
|
||||
<div className={cx(styles.icon, { [bem(styles.icon, 'active')]: step === TutorialStep.Escalations })}>
|
||||
<img src={escalationIcon} />
|
||||
|
|
@ -47,7 +47,7 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
<Text type="secondary">Setup escalation chain to handle notifications</Text>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={cx(styles.step)}>
|
||||
<div className={styles.step}>
|
||||
<PluginLink query={{ page: 'chat-ops' }}>
|
||||
<div className={cx(styles.icon, { [bem(styles.icon, 'active')]: step === TutorialStep.Slack })}>
|
||||
<img src={chatIcon} />
|
||||
|
|
@ -56,7 +56,7 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
<Text type="secondary">Connect to your chat workspace</Text>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={cx(styles.step)}>
|
||||
<div className={styles.step}>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<div className={cx(styles.icon, { [bem(styles.icon, 'active')]: step === TutorialStep.Schedules })}>
|
||||
<img src={scheduleIcon} />
|
||||
|
|
@ -65,7 +65,7 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
<Text type="secondary">Add your team calendar to define an on-call rotation.</Text>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={cx(styles.step)}>
|
||||
<div className={styles.step}>
|
||||
<PluginLink query={{ page: 'alert-groups' }}>
|
||||
<div className={cx('icon', { [bem(styles.icon, 'active')]: step === TutorialStep.Incidents })}>
|
||||
<img src={bellIcon} />
|
||||
|
|
@ -81,7 +81,7 @@ export const Tutorial: FC<TutorialProps> = (props) => {
|
|||
const Arrow = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={cx(styles.arrow)}>
|
||||
<div className={styles.arrow}>
|
||||
<svg width="41" height="16" viewBox="0 0 41 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M40.7071 8.70711C41.0976 8.31658 41.0976 7.68342 40.7071 7.29289L34.3431 0.928932C33.9526 0.538408 33.3195 0.538408 32.9289 0.928932C32.5384 1.31946 32.5384 1.95262 32.9289 2.34315L38.5858 8L32.9289 13.6569C32.5384 14.0474 32.5384 14.6805 32.9289 15.0711C33.3195 15.4616 33.9526 15.4616 34.3431 15.0711L40.7071 8.70711ZM0 9H40V7H0V9Z" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, OrgRole } from '@grafana/data';
|
||||
import { VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'grafana/app/core/core';
|
||||
|
|
@ -16,9 +16,9 @@ export const Unauthorized: FC<Props> = ({ requiredUserAction: { permission, fall
|
|||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.notFound)}>
|
||||
<div className={styles.notFound}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1} className={cx(styles.errorCode)}>
|
||||
<Text.Title level={1} className={styles.errorCode}>
|
||||
403
|
||||
</Text.Title>
|
||||
<Text.Title level={4}>
|
||||
|
|
|
|||
|
|
@ -95,14 +95,14 @@ export const UserGroups = (props: UserGroupsProps) => {
|
|||
};
|
||||
|
||||
const renderItem = (item: Item, index: number) => (
|
||||
<li className={cx(styles.user)}>
|
||||
<li className={styles.user}>
|
||||
{renderUser(item.data)}
|
||||
{!disabled && (
|
||||
<div className={cx(styles.userButtons)}>
|
||||
<div className={styles.userButtons}>
|
||||
<HorizontalGroup>
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
className={cx(styles.icon)}
|
||||
className={styles.icon}
|
||||
name="trash-alt"
|
||||
onClick={getDeleteItemHandler(index)}
|
||||
/>
|
||||
|
|
@ -114,7 +114,7 @@ export const UserGroups = (props: UserGroupsProps) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)}>
|
||||
<div className={styles.root}>
|
||||
<VerticalGroup>
|
||||
{!disabled && (
|
||||
<RemoteSelect
|
||||
|
|
@ -133,7 +133,7 @@ export const UserGroups = (props: UserGroupsProps) => {
|
|||
renderItem={renderItem}
|
||||
axis="y"
|
||||
lockAxis="y"
|
||||
helperClass={cx(styles.sortable)}
|
||||
helperClass={styles.sortable}
|
||||
items={items}
|
||||
onSortEnd={onSortEnd}
|
||||
handleAddGroup={handleAddUserGroup}
|
||||
|
|
@ -178,7 +178,7 @@ export const SortableList = SortableContainer<SortableListProps>(
|
|||
}, [items]);
|
||||
|
||||
return (
|
||||
<ul className={cx(styles.groups)} ref={listRef}>
|
||||
<ul className={styles.groups} ref={listRef}>
|
||||
{items.map((item, index) =>
|
||||
item.type === 'item' ? (
|
||||
<SortableItem key={item.key} index={index}>
|
||||
|
|
@ -186,7 +186,7 @@ export const SortableList = SortableContainer<SortableListProps>(
|
|||
</SortableItem>
|
||||
) : isMultipleGroups ? (
|
||||
<SortableItem key={item.key} index={index}>
|
||||
<li className={cx(styles.separator)}>
|
||||
<li className={styles.separator}>
|
||||
<Text type="secondary">{item.data.name}</Text>
|
||||
</li>
|
||||
</SortableItem>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const VerticalTabsBar = (props: VerticalTabsBarProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root)}>
|
||||
<div className={styles.root}>
|
||||
{React.Children.toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child: React.ReactElement, idx) => (
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
.body {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.responders-list {
|
||||
list-style-type: none;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
& > li .hover-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > li:hover .hover-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
& > li {
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& > li:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-icon-background {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--timeline-icon-background);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
& > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #299c46;
|
||||
}
|
||||
}
|
||||
|
||||
.responder-name {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.confirm-participant-invitation-modal {
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.confirm-participant-invitation-modal-select {
|
||||
display: inline-flex;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.learn-more-link {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getAddRespondersStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
content: css`
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
respondersList: css`
|
||||
padding-top: 8px;
|
||||
list-style-type: none;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
& > li .hover-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > li:hover .hover-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
& > li {
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& > li:hover {
|
||||
background: ${theme.colors.background.secondary};
|
||||
}
|
||||
`,
|
||||
|
||||
alert: css`
|
||||
padding-top: 4px;
|
||||
`,
|
||||
|
||||
timelineIconBackground: css`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(${theme.isDark ? '70, 76, 84, 1' : '70, 76, 84, 0'});
|
||||
|
||||
& > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #299c46;
|
||||
}
|
||||
`,
|
||||
|
||||
responderName: css`
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
confirmParticipantInvitationModal: css`
|
||||
max-width: 550px;
|
||||
`,
|
||||
|
||||
confirmParticipantInvitationModalSelect: css`
|
||||
display: inline-flex;
|
||||
margin: 0 4px;
|
||||
`,
|
||||
|
||||
learnMoreLink: css`
|
||||
display: inline-block;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { HorizontalGroup, Button, Modal, Alert, VerticalGroup, Icon } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { HorizontalGroup, Button, Modal, Alert, VerticalGroup, Icon, useStyles2 } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -14,15 +13,13 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import styles from './AddResponders.module.scss';
|
||||
import { getAddRespondersStyles } from './AddResponders.styles';
|
||||
import { NotificationPolicyValue, UserResponder as UserResponderType } from './AddResponders.types';
|
||||
import { AddRespondersPopup } from './parts/AddRespondersPopup/AddRespondersPopup';
|
||||
import { NotificationPoliciesSelect } from './parts/NotificationPoliciesSelect/NotificationPoliciesSelect';
|
||||
import { TeamResponder } from './parts/TeamResponder/TeamResponder';
|
||||
import { UserResponder } from './parts/UserResponder/UserResponder';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'update';
|
||||
hideAddResponderButton?: boolean;
|
||||
|
|
@ -31,21 +28,24 @@ type Props = {
|
|||
generateRemovePreviouslyPagedUserCallback?: (userId: string) => () => Promise<void>;
|
||||
};
|
||||
|
||||
const LearnMoreAboutNotificationPoliciesLink: React.FC = () => (
|
||||
<a
|
||||
className={cx('learn-more-link')}
|
||||
href="https://grafana.com/docs/oncall/latest/notify/#configure-user-notification-policies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">
|
||||
<HorizontalGroup spacing="xs">
|
||||
Learn more
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
);
|
||||
const LearnMoreAboutNotificationPoliciesLink: React.FC = () => {
|
||||
const styles = useStyles2(getAddRespondersStyles);
|
||||
return (
|
||||
<a
|
||||
className={styles.learnMoreLink}
|
||||
href="https://grafana.com/docs/oncall/latest/notify/#configure-user-notification-policies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">
|
||||
<HorizontalGroup spacing="xs">
|
||||
Learn more
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddResponders = observer(
|
||||
({
|
||||
|
|
@ -56,6 +56,8 @@ export const AddResponders = observer(
|
|||
generateRemovePreviouslyPagedUserCallback,
|
||||
}: Props) => {
|
||||
const { directPagingStore } = useStore();
|
||||
const styles = useStyles2(getAddRespondersStyles);
|
||||
|
||||
const { selectedTeamResponder, selectedUserResponders } = directPagingStore;
|
||||
|
||||
const currentMoment = useMemo(() => dayjs(), []);
|
||||
|
|
@ -107,7 +109,7 @@ export const AddResponders = observer(
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('body')}>
|
||||
<div className={styles.content}>
|
||||
<Block bordered>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text.Title type="primary" level={4}>
|
||||
|
|
@ -129,7 +131,7 @@ export const AddResponders = observer(
|
|||
</HorizontalGroup>
|
||||
{(selectedTeamResponder || existingPagedUsers.length > 0 || selectedUserResponders.length > 0) && (
|
||||
<>
|
||||
<ul className={cx('responders-list')}>
|
||||
<ul className={styles.respondersList}>
|
||||
{selectedTeamResponder && (
|
||||
<TeamResponder team={selectedTeamResponder} handleDelete={directPagingStore.resetSelectedTeam} />
|
||||
)}
|
||||
|
|
@ -156,6 +158,7 @@ export const AddResponders = observer(
|
|||
{selectedUserResponders.length > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
className={styles.alert}
|
||||
title={
|
||||
(
|
||||
<Text type="primary">
|
||||
|
|
@ -184,7 +187,7 @@ export const AddResponders = observer(
|
|||
isOpen
|
||||
title="Confirm Participant Invitation"
|
||||
onDismiss={closeUserConfirmationModal}
|
||||
className={cx('confirm-participant-invitation-modal')}
|
||||
className={styles.confirmParticipantInvitationModal}
|
||||
>
|
||||
<VerticalGroup spacing="md">
|
||||
{!isCreateMode && (
|
||||
|
|
@ -194,7 +197,7 @@ export const AddResponders = observer(
|
|||
{currentMoment.tz(UserHelper.getTimezone(currentlyConsideredUser)).format('HH:mm')}) will be
|
||||
notified using
|
||||
</Text>
|
||||
<div className={cx('confirm-participant-invitation-modal-select')}>
|
||||
<div className={styles.confirmParticipantInvitationModalSelect}>
|
||||
<NotificationPoliciesSelect
|
||||
important={Boolean(currentlyConsideredUserNotificationPolicy)}
|
||||
onChange={onChangeCurrentlyConsideredUserNotificationPolicy}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
exports[`AddResponders should properly display the add responders button when hideAddResponderButton is false 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
class="css-1si66qn"
|
||||
>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--bordered"
|
||||
|
|
@ -56,7 +56,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
exports[`AddResponders should properly display the add responders button when hideAddResponderButton is true 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
class="css-1si66qn"
|
||||
>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--bordered"
|
||||
|
|
@ -90,7 +90,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
exports[`AddResponders should render properly in create mode 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
class="css-1si66qn"
|
||||
>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--bordered"
|
||||
|
|
@ -143,7 +143,7 @@ exports[`AddResponders should render properly in create mode 1`] = `
|
|||
exports[`AddResponders should render properly in update mode 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
class="css-1si66qn"
|
||||
>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--bordered"
|
||||
|
|
@ -196,7 +196,7 @@ exports[`AddResponders should render properly in update mode 1`] = `
|
|||
exports[`AddResponders should render selected team and users properly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
class="css-1si66qn"
|
||||
>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--bordered"
|
||||
|
|
@ -239,7 +239,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
<ul
|
||||
class="responders-list"
|
||||
class="css-xp2upo"
|
||||
>
|
||||
<li>
|
||||
<div
|
||||
|
|
@ -257,7 +257,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background"
|
||||
class="css-1yiiywv"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -270,7 +270,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
@ -310,7 +310,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
class="css-1yiiywv timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -323,7 +323,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
my test user3
|
||||
</span>
|
||||
|
|
@ -425,7 +425,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
class="css-1yiiywv timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -438,7 +438,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
my test user
|
||||
</span>
|
||||
|
|
@ -539,7 +539,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
class="css-1yiiywv timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -552,7 +552,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
my test user2
|
||||
</span>
|
||||
|
|
@ -639,7 +639,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
</li>
|
||||
<div
|
||||
aria-label="[object Object]"
|
||||
class="css-10yjoiw"
|
||||
class="css-10yjoiw css-182y09v"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
>
|
||||
|
|
@ -667,7 +667,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
<a
|
||||
class="learn-more-link"
|
||||
class="css-1cvxpvr"
|
||||
href="https://grafana.com/docs/oncall/latest/notify/#configure-user-notification-policies"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -1,35 +1,36 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { HorizontalGroup, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { HorizontalGroup, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import styles from 'containers/AddResponders/AddResponders.module.scss';
|
||||
import { getAddRespondersStyles } from 'containers/AddResponders/AddResponders.styles';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
type Props = {
|
||||
team: GrafanaTeam | null;
|
||||
handleDelete: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export const TeamResponder: FC<Props> = ({ team: { avatar_url, name }, handleDelete }) => (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background')}>
|
||||
<Avatar size="medium" src={avatar_url} />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{name}</Text>
|
||||
export const TeamResponder: FC<Props> = ({ team: { avatar_url, name }, handleDelete }) => {
|
||||
const styles = useStyles2(getAddRespondersStyles);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={styles.timelineIconBackground}>
|
||||
<Avatar size="medium" src={avatar_url} />
|
||||
</div>
|
||||
<Text className={styles.responderName}>{name}</Text>
|
||||
</HorizontalGroup>
|
||||
<IconButton
|
||||
data-testid="team-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<IconButton
|
||||
data-testid="team-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ exports[`TeamResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background"
|
||||
class="css-1yiiywv"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -31,7 +31,7 @@ exports[`TeamResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { cx } from '@emotion/css';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { ActionMeta, HorizontalGroup, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { ActionMeta, HorizontalGroup, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import styles from 'containers/AddResponders/AddResponders.module.scss';
|
||||
import { getAddRespondersStyles } from 'containers/AddResponders/AddResponders.styles';
|
||||
import { UserResponder as UserResponderType } from 'containers/AddResponders/AddResponders.types';
|
||||
import { NotificationPoliciesSelect } from 'containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
type Props = UserResponderType & {
|
||||
onImportantChange: (value: SelectableValue<number>, actionMeta: ActionMeta) => void | {};
|
||||
handleDelete: React.MouseEventHandler<HTMLButtonElement>;
|
||||
|
|
@ -24,28 +22,32 @@ export const UserResponder: FC<Props> = ({
|
|||
onImportantChange,
|
||||
handleDelete,
|
||||
disableNotificationPolicySelect = false,
|
||||
}) => (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
|
||||
<Avatar size="medium" src={avatar} />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{username}</Text>
|
||||
}) => {
|
||||
const styles = useStyles2(getAddRespondersStyles);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx(styles.timelineIconBackground, { 'timeline-icon-background--green': true })}>
|
||||
<Avatar size="medium" src={avatar} />
|
||||
</div>
|
||||
<Text className={styles.responderName}>{username}</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<NotificationPoliciesSelect
|
||||
disabled={disableNotificationPolicySelect}
|
||||
important={important}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<IconButton
|
||||
data-testid="user-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<NotificationPoliciesSelect
|
||||
disabled={disableNotificationPolicySelect}
|
||||
important={important}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<IconButton
|
||||
data-testid="user-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ exports[`UserResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
class="css-1yiiywv timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="css-m6de9j css-m6de9j--medium"
|
||||
|
|
@ -31,7 +31,7 @@ exports[`UserResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
>
|
||||
johnsmith
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { Organization } from 'models/organization/organization.types';
|
||||
|
||||
import { SlackError } from './DefaultPageLayout.types';
|
||||
|
|
@ -10,12 +11,15 @@ export function getSlackMessage(slackError: SlackError, organization: Organizati
|
|||
return (
|
||||
<>
|
||||
Couldn't connect Slack.
|
||||
{Boolean(organization?.slack_team_identity) && (
|
||||
<>
|
||||
{' '}
|
||||
Select <b>{organization.slack_team_identity.cached_name}</b> workspace when connecting please
|
||||
</>
|
||||
)}
|
||||
<RenderConditionally
|
||||
shouldRender={Boolean(organization?.slack_team_identity)}
|
||||
render={() => (
|
||||
<>
|
||||
{' '}
|
||||
Select <b>{organization.slack_team_identity.cached_name}</b> workspace when connecting please
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ function prepareDataForEdit(
|
|||
|
||||
function prepareForSave(rawData: Partial<ApiSchemas['Webhook']>, selectedPreset: OutgoingWebhookPreset) {
|
||||
const data = { ...rawData };
|
||||
selectedPreset.controlled_fields.forEach((field) => {
|
||||
selectedPreset?.controlled_fields.forEach((field) => {
|
||||
delete data[field];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ export const OutgoingWebhookFormFields = ({
|
|||
return (
|
||||
<>
|
||||
{React.Children.toArray(controls.props.children).filter(
|
||||
(child) => !preset || !preset.controlled_fields.includes((child as React.ReactElement).props.name)
|
||||
(child) => !preset?.controlled_fields.includes((child as React.ReactElement).props.name)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const additionalWebhookPresetIcons: { [id: string]: () => React.ReactElem
|
|||
};
|
||||
|
||||
export const getWebhookPresetIcons = (features: Record<string, boolean>) => {
|
||||
if (features[AppFeature.MsTeams]) {
|
||||
if (features?.[AppFeature.MsTeams]) {
|
||||
return { ...commonWebhookPresetIconsConfig, ...additionalWebhookPresetIcons };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -202,9 +202,11 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const onError = useCallback((error) => {
|
||||
setErrors(error.response.data);
|
||||
}, []);
|
||||
const onError = (error) => {
|
||||
if (error.response?.data) {
|
||||
setErrors(error.response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = useDebouncedCallback(updatePreview, 200);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +1,3 @@
|
|||
.root {
|
||||
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: var(--gradient-brandVertical);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
transition: left 500ms ease;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.layer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rotations {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layer-title {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
padding: 8px;
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
.layer-title:hover {
|
||||
background: rgba(204, 204, 220, 0.12);
|
||||
}
|
||||
|
||||
.rotations-plus-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-plus-content {
|
||||
position: relative;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.layer-header-title {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
}
|
||||
|
||||
.layer-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-rotations-layer {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-rotations-layer:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
/*
|
||||
animation
|
||||
*/
|
||||
|
||||
.enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
|
|
|||
93
grafana-plugin/src/containers/Rotations/Rotations.styles.ts
Normal file
93
grafana-plugin/src/containers/Rotations/Rotations.styles.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getRotationsStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
root: css`
|
||||
background: 1px solid ${theme.colors.background.secondary};
|
||||
border: ${theme.colors.border.weak};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
`,
|
||||
|
||||
currentTime: css`
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background: ${theme.colors.gradients.brandVertical}
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
transition: left 500ms ease;
|
||||
`,
|
||||
|
||||
header: css`
|
||||
padding: 0 10px;
|
||||
`,
|
||||
|
||||
title: css`
|
||||
margin: 16px 0;
|
||||
`,
|
||||
|
||||
layer: css`
|
||||
display: block;
|
||||
`,
|
||||
|
||||
rotations: css`
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
layerTitle: css`
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
padding: 8px;
|
||||
background: ${theme.colors.background.secondary};
|
||||
|
||||
&:hover {
|
||||
background: rgba(204, 204, 220, 0.12);
|
||||
}
|
||||
`,
|
||||
|
||||
rotationsPlusTitle: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
|
||||
headerPlusContent: css`
|
||||
position: relative;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
`,
|
||||
|
||||
layerHeader: css`
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
|
||||
layerHeaderTitle: css`
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: rgba(204, 204, 220, 0.65);
|
||||
`,
|
||||
|
||||
layerContent: css`
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
addRotationsLayer: css`
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.background.secondary};
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { ValuePicker, HorizontalGroup, Button, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { ValuePicker, HorizontalGroup, Button, Tooltip, withTheme2 } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
|
@ -22,10 +21,9 @@ import { UserActions } from 'utils/authorization/authorization';
|
|||
|
||||
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
|
||||
import { findColor } from './Rotations.helpers';
|
||||
import { getRotationsStyles } from './Rotations.styles';
|
||||
|
||||
import styles from './Rotations.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
import animationStyles from './Rotations.module.css';
|
||||
|
||||
interface RotationsProps extends WithStoreProps {
|
||||
shiftIdToShowRotationForm?: Shift['id'] | 'new';
|
||||
|
|
@ -41,6 +39,7 @@ interface RotationsProps extends WithStoreProps {
|
|||
disabled: boolean;
|
||||
filters: ScheduleFiltersType;
|
||||
onSlotClick?: (event: Event) => void;
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
interface RotationsState {
|
||||
|
|
@ -69,6 +68,7 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
filters,
|
||||
onShowShiftSwapForm,
|
||||
onSlotClick,
|
||||
theme,
|
||||
} = this.props;
|
||||
const { layerPriority, shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state;
|
||||
|
||||
|
|
@ -97,13 +97,14 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
|
||||
const isTypeReadOnly =
|
||||
schedule && (schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar);
|
||||
const styles = getRotationsStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('header')}>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.title}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Rotations
|
||||
</Text.Title>
|
||||
|
|
@ -145,28 +146,32 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('rotations-plus-title')}>
|
||||
<div className={styles.rotationsPlusTitle}>
|
||||
{layers && layers.length ? (
|
||||
<TransitionGroup className={cx('layers')}>
|
||||
<TransitionGroup>
|
||||
{layers.map((layer, layerIndex) => (
|
||||
<CSSTransition key={layerIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<div id={`layer${layer.priority}`} className={cx('layer')}>
|
||||
<div className={cx('layer-title')}>
|
||||
<CSSTransition
|
||||
key={layerIndex}
|
||||
timeout={DEFAULT_TRANSITION_TIMEOUT}
|
||||
classNames={{ ...animationStyles }}
|
||||
>
|
||||
<div id={`layer${layer.priority}`} className={styles.layer}>
|
||||
<div className={styles.layerTitle}>
|
||||
<HorizontalGroup spacing="sm" justify="center">
|
||||
<Text type="secondary">Layer {layer.priority}</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('header-plus-content')}>
|
||||
<div className={styles.headerPlusContent}>
|
||||
<TimelineMarks />
|
||||
{!currentTimeHidden && (
|
||||
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
|
||||
<div className={styles.currentTime} style={{ left: `${currentTimeX * 100}%` }} />
|
||||
)}
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
<TransitionGroup className={styles.rotations}>
|
||||
{layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
|
||||
<CSSTransition
|
||||
key={rotationIndex}
|
||||
timeout={DEFAULT_TRANSITION_TIMEOUT}
|
||||
classNames={{ ...styles }}
|
||||
classNames={{ ...animationStyles }}
|
||||
>
|
||||
<Rotation
|
||||
onClick={(shiftStart, shiftEnd) => {
|
||||
|
|
@ -194,16 +199,16 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
</TransitionGroup>
|
||||
) : (
|
||||
<div>
|
||||
<div id={`layer1`} className={cx('layer')}>
|
||||
<div className={cx('layer-title')}>
|
||||
<div id={`layer1`} className={styles.layer}>
|
||||
<div className={styles.layerTitle}>
|
||||
<HorizontalGroup spacing="sm" justify="center">
|
||||
<Text type="secondary">Layer 1</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('header-plus-content')}>
|
||||
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
|
||||
<div className={styles.headerPlusContent}>
|
||||
<div className={styles.currentTime} style={{ left: `${currentTimeX * 100}%` }} />
|
||||
<TimelineMarks />
|
||||
<div className={cx('rotations')}>
|
||||
<div className={styles.rotations}>
|
||||
<Rotation
|
||||
onClick={(shiftStart, shiftEnd) => {
|
||||
this.handleAddLayer(nextPriority, shiftStart, shiftEnd);
|
||||
|
|
@ -219,7 +224,7 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
)}
|
||||
{nextPriority > 1 && (
|
||||
<div
|
||||
className={cx('add-rotations-layer')}
|
||||
className={styles.addRotationsLayer}
|
||||
onClick={() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
|
|
@ -232,6 +237,7 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shiftIdToShowRotationForm && (
|
||||
<RotationForm
|
||||
shiftId={shiftIdToShowRotationForm}
|
||||
|
|
@ -338,4 +344,4 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
|
|||
};
|
||||
}
|
||||
|
||||
export const Rotations = withMobXProviderContext(_Rotations);
|
||||
export const Rotations = withMobXProviderContext(withTheme2(_Rotations));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { HorizontalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
|
@ -22,10 +21,9 @@ import { withMobXProviderContext } from 'state/withStore';
|
|||
|
||||
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
|
||||
import { findColor } from './Rotations.helpers';
|
||||
import { getRotationsStyles } from './Rotations.styles';
|
||||
|
||||
import styles from './Rotations.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
import animationStyles from './Rotations.module.css';
|
||||
|
||||
interface ScheduleFinalProps extends WithStoreProps {
|
||||
scheduleId: Schedule['id'];
|
||||
|
|
@ -45,6 +43,8 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
|
|||
const base = 7 * 24 * 60; // in minutes
|
||||
const diff = currentDateInSelectedTimezone.diff(calendarStartDate, 'minutes');
|
||||
|
||||
const styles = useStyles2(getRotationsStyles);
|
||||
|
||||
const currentTimeX = diff / base;
|
||||
|
||||
const shifts = flattenShiftEvents(getShiftsFromStore(store, scheduleId, calendarStartDate));
|
||||
|
|
@ -62,11 +62,11 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={styles.root}>
|
||||
{!simplified && (
|
||||
<div className={cx('header')}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.title}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Final schedule
|
||||
</Text.Title>
|
||||
|
|
@ -74,14 +74,14 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
|
|||
</HorizontalGroup>
|
||||
</div>
|
||||
)}
|
||||
<div className={cx('header-plus-content')}>
|
||||
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<div className={styles.headerPlusContent}>
|
||||
{!currentTimeHidden && <div className={styles.currentTime} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<TimelineMarks />
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
<TransitionGroup className={styles.rotations}>
|
||||
{shifts && shifts.length ? (
|
||||
shifts.map(({ events }, index) => {
|
||||
return (
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation
|
||||
key={index}
|
||||
events={events}
|
||||
|
|
@ -97,7 +97,7 @@ const _ScheduleFinal: FC<ScheduleFinalProps> = observer(
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation events={[]} />
|
||||
</CSSTransition>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, Tooltip, withTheme2 } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
|
@ -27,10 +27,9 @@ import { UserActions } from 'utils/authorization/authorization';
|
|||
|
||||
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
|
||||
import { findClosestUserEvent, findColor } from './Rotations.helpers';
|
||||
import { getRotationsStyles } from './Rotations.styles';
|
||||
|
||||
import styles from './Rotations.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
import animationStyles from './Rotations.module.css';
|
||||
|
||||
interface ScheduleOverridesProps extends WithStoreProps {
|
||||
shiftStartToShowOverrideForm: dayjs.Dayjs;
|
||||
|
|
@ -45,6 +44,7 @@ interface ScheduleOverridesProps extends WithStoreProps {
|
|||
disabled: boolean;
|
||||
disableShiftSwaps: boolean;
|
||||
filters: ScheduleFiltersType;
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
interface ScheduleOverridesState {
|
||||
|
|
@ -76,7 +76,9 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
store: {
|
||||
userStore: { currentUserPk },
|
||||
},
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const { shiftStartToShowOverrideForm, shiftEndToShowOverrideForm } = this.state;
|
||||
|
||||
const shifts = getOverridesFromStore(store, scheduleId, store.timezoneStore.calendarStartDate) as ShiftEvents[];
|
||||
|
|
@ -98,13 +100,14 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
const schedule = store.scheduleStore.items[scheduleId];
|
||||
|
||||
const isTypeReadOnly = !schedule?.enable_web_overrides;
|
||||
const styles = getRotationsStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="overrides-list" className={cx('root')}>
|
||||
<div className={cx('header')}>
|
||||
<div id="overrides-list" className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.title}>
|
||||
<Text.Title level={4} type="primary">
|
||||
Overrides and swaps
|
||||
</Text.Title>
|
||||
|
|
@ -147,13 +150,13 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('header-plus-content')}>
|
||||
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<div className={styles.headerPlusContent}>
|
||||
{!currentTimeHidden && <div className={styles.currentTime} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<TimelineMarks />
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
<TransitionGroup className={styles.rotations}>
|
||||
{shiftSwaps && shiftSwaps.length
|
||||
? shiftSwaps.map(({ isPreview, events }, index) => (
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation
|
||||
events={events}
|
||||
color={SHIFT_SWAP_COLOR}
|
||||
|
|
@ -170,10 +173,10 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
))
|
||||
: null}
|
||||
</TransitionGroup>
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
<TransitionGroup className={styles.rotations}>
|
||||
{shifts && shifts.length ? (
|
||||
shifts.map(({ shiftId, isPreview, events }, index) => (
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation
|
||||
events={events}
|
||||
color={getOverrideColor(index)}
|
||||
|
|
@ -186,7 +189,7 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
</CSSTransition>
|
||||
))
|
||||
) : (
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation
|
||||
key={0}
|
||||
events={[]}
|
||||
|
|
@ -273,4 +276,4 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
|
|||
};
|
||||
}
|
||||
|
||||
export const ScheduleOverrides = withMobXProviderContext(_ScheduleOverrides);
|
||||
export const ScheduleOverrides = withMobXProviderContext(withTheme2(_ScheduleOverrides));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { FC, useEffect } from 'react';
|
||||
|
||||
import { Badge, Button, HorizontalGroup, Icon } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { Badge, Button, HorizontalGroup, Icon, useStyles2 } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
|
|
@ -20,10 +19,9 @@ import { PLUGIN_ROOT } from 'utils/consts';
|
|||
import { useIsLoading } from 'utils/hooks';
|
||||
|
||||
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
|
||||
import { getRotationsStyles } from './Rotations.styles';
|
||||
|
||||
import styles from './Rotations.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
import animationStyles from './Rotations.module.css';
|
||||
|
||||
interface SchedulePersonalProps extends RouteComponentProps {
|
||||
userPk: ApiSchemas['User']['pk'];
|
||||
|
|
@ -78,10 +76,12 @@ const _SchedulePersonal: FC<SchedulePersonalProps> = observer(({ userPk, onSlotC
|
|||
|
||||
const emptyRotationsText = updatePersonalEventsLoading ? 'Loading ...' : 'There are no schedules relevant to user';
|
||||
|
||||
const styles = useStyles2(getRotationsStyles);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('header')}>
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">
|
||||
|
|
@ -116,14 +116,14 @@ const _SchedulePersonal: FC<SchedulePersonalProps> = observer(({ userPk, onSlotC
|
|||
</HorizontalGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('header-plus-content')}>
|
||||
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<div className={styles.headerPlusContent}>
|
||||
{!currentTimeHidden && <div className={styles.currentTime} style={{ left: `${currentTimeX * 100}%` }} />}
|
||||
<TimelineMarks />
|
||||
<TransitionGroup className={cx('rotations')}>
|
||||
<TransitionGroup className={styles.rotations}>
|
||||
{shifts?.length ? (
|
||||
shifts.map(({ events }, index) => {
|
||||
return (
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation
|
||||
simplified
|
||||
key={index}
|
||||
|
|
@ -137,7 +137,7 @@ const _SchedulePersonal: FC<SchedulePersonalProps> = observer(({ userPk, onSlotC
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
|
||||
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...animationStyles }}>
|
||||
<Rotation events={[]} emptyText={emptyRotationsText} />
|
||||
</CSSTransition>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -289,11 +289,11 @@ const ScheduleNotificationSettingsFields = () => {
|
|||
invalid={!!errors.user_group}
|
||||
error={errors.user_group?.message}
|
||||
>
|
||||
<GSelect<UserGroup[]>
|
||||
<GSelect<UserGroup>
|
||||
allowClear
|
||||
items={userGroupStore.items}
|
||||
fetchItemsFn={userGroupStore.updateItems}
|
||||
fetchItemFn={() => undefined}
|
||||
fetchItemFn={userGroupStore.fetchItemById}
|
||||
getSearchResult={userGroupStore.getSearchResult}
|
||||
displayField="handle"
|
||||
placeholder="Select User Group"
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
.root {
|
||||
height: 28px;
|
||||
background: #595959;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
margin: 0 1px;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.working-hours {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root__type_gap {
|
||||
background: rgba(209, 14, 92, 0.2);
|
||||
border: 1px dashed #ff5286;
|
||||
color: rgba(209, 14, 92, 0.5);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.root__type_shift-swap {
|
||||
border-radius: 10px;
|
||||
background: #ff99002e;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.no-user {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--tag-background-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root__inactive {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.title {
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
line-height: 16px;
|
||||
z-index: 1;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details {
|
||||
width: 300px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.details-user-status {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.details-user-status__type_success {
|
||||
background-color: var(--success-text-color);
|
||||
}
|
||||
|
||||
.time {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.is-oncall-icon {
|
||||
color: var(--oncall-icon-stroke-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.details-icon {
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.username {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.second-column {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { FC, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, Icon, Tooltip, useStyles2, VerticalGroup } from '@grafana/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { observer } from 'mobx-react';
|
||||
import { COLORS, getLabelCss } from 'styles/utils.styles';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import NonExistentUserName from 'components/NonExistentUserName/NonExistentUserName';
|
||||
|
|
@ -19,8 +21,6 @@ import { useStore } from 'state/useStore';
|
|||
|
||||
import { getTitle } from './ScheduleSlot.helpers';
|
||||
|
||||
import styles from './ScheduleSlot.module.css';
|
||||
|
||||
interface ScheduleSlotProps {
|
||||
event: Event;
|
||||
handleAddOverride: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
|
|
@ -33,13 +33,14 @@ interface ScheduleSlotProps {
|
|||
showScheduleNameAsSlotTitle?: boolean;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
const ONE_WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
||||
const {
|
||||
timezoneStore: { getDateInSelectedTimezone },
|
||||
} = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const {
|
||||
event,
|
||||
color,
|
||||
|
|
@ -69,7 +70,7 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
if (event.is_gap) {
|
||||
return (
|
||||
<Tooltip content={<ScheduleGapDetails event={event} />}>
|
||||
<div className={cx('root', 'root__type_gap')} />
|
||||
<div className={cx(styles.root, styles.gap)} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,7 +81,7 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
shouldRender={event.missing_users.length > 0}
|
||||
backupChildren={
|
||||
<div
|
||||
className={cx('root')}
|
||||
className={styles.root}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
|
|
@ -90,12 +91,12 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
{event.missing_users.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className={cx('root')}
|
||||
className={styles.root}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.title}>
|
||||
<NonExistentUserName userName={name} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +123,7 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cx('stack')} style={{ width: `${width * 100}%` }} onClick={onClick}>
|
||||
<div className={styles.stack} style={{ width: `${width * 100}%` }} onClick={onClick}>
|
||||
{renderEvent(event)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -137,6 +138,7 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
|
|||
const { event, currentMoment } = props;
|
||||
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const shiftSwap = store.scheduleStore.shiftSwaps[event.shiftSwapId];
|
||||
|
||||
|
|
@ -159,14 +161,14 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
|
|||
const benefactorStoreUser = store.userStore.items[shiftSwap?.benefactor?.pk];
|
||||
|
||||
const scheduleSlotContent = (
|
||||
<div className={cx('root', { 'root__type_shift-swap': true })} data-testid="schedule-slot">
|
||||
<div className={cx(styles.root, styles.swap)} data-testid="schedule-slot">
|
||||
{shiftSwap && (
|
||||
<HorizontalGroup spacing="xs">
|
||||
{beneficiary && <Avatar size="xs" src={beneficiary.avatar_full} />}
|
||||
{benefactor ? (
|
||||
<Avatar size="xs" src={benefactor.avatar_full} />
|
||||
) : (
|
||||
<div className={cx('no-user')}>
|
||||
<div className={styles.noUser}>
|
||||
<Text size="xs" type="primary">
|
||||
?
|
||||
</Text>
|
||||
|
|
@ -231,6 +233,7 @@ const RegularEvent = (props: RegularEventProps) => {
|
|||
showScheduleNameAsSlotTitle,
|
||||
} = props;
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { users } = event;
|
||||
|
||||
|
|
@ -268,7 +271,7 @@ const RegularEvent = (props: RegularEventProps) => {
|
|||
|
||||
const scheduleSlotContent = (
|
||||
<div
|
||||
className={cx('root', { root__inactive: inactive })}
|
||||
className={cx(styles.root, { [styles.inactive]: inactive })}
|
||||
style={{
|
||||
backgroundColor,
|
||||
}}
|
||||
|
|
@ -277,14 +280,14 @@ const RegularEvent = (props: RegularEventProps) => {
|
|||
>
|
||||
{storeUser && (!swap_request || swap_request.user) && (
|
||||
<WorkingHours
|
||||
className={cx('working-hours')}
|
||||
className={styles.workingHours}
|
||||
timezone={storeUser.timezone}
|
||||
workingHours={storeUser.working_hours}
|
||||
startMoment={start}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={cx('title')}>
|
||||
<div className={styles.title}>
|
||||
{swap_request && !swap_request.user ? <Icon name="user-arrows" /> : userTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -374,6 +377,8 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => {
|
|||
|
||||
const enableWebOverrides = schedule?.enable_web_overrides;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
if (shiftId && !scheduleStore.shifts[shiftId]) {
|
||||
scheduleStore.updateOncallShift(shiftId);
|
||||
|
|
@ -390,47 +395,47 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => {
|
|||
// const isOncall = Boolean(storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk));
|
||||
|
||||
return (
|
||||
<div className={cx('details')}>
|
||||
<div className={styles.details}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup>
|
||||
<div className={cx('details-icon')}>
|
||||
<div className={cx('badge')} style={{ backgroundColor: color }} />
|
||||
<div className={styles.detailsIcon}>
|
||||
<div className={styles.badge} style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
<Text type="primary" maxWidth="222px">
|
||||
{title}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<div className={cx('details-icon')}>
|
||||
<Icon className={cx('icon')} name={isShiftSwap ? 'user-arrows' : 'user'} />
|
||||
<div className={styles.detailsIcon}>
|
||||
<Icon className={styles.icon} name={isShiftSwap ? 'user-arrows' : 'user'} />
|
||||
</div>
|
||||
{isShiftSwap ? (
|
||||
<VerticalGroup spacing="xs">
|
||||
<Text type="primary">Swap pair</Text>
|
||||
<Text type="primary" className={cx('username')}>
|
||||
<Text type="primary" className={styles.username}>
|
||||
{beneficiaryName} <Text type="secondary"> (requested by)</Text>
|
||||
</Text>
|
||||
{benefactorName ? (
|
||||
<Text type="primary" className={cx('username')}>
|
||||
<Text type="primary" className={styles.username}>
|
||||
{benefactorName} <Text type="secondary"> (accepted by)</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" className={cx('username')}>
|
||||
<Text type="secondary" className={styles.username}>
|
||||
Not accepted yet
|
||||
</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
) : (
|
||||
<Text type="primary" className={cx('username')}>
|
||||
<Text type="primary" className={styles.username}>
|
||||
{user?.username}
|
||||
</Text>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<div className={cx('details-icon')}>
|
||||
<Icon className={cx('icon')} name="clock-nine" />
|
||||
<div className={styles.detailsIcon}>
|
||||
<Icon className={styles.icon} name="clock-nine" />
|
||||
</div>
|
||||
<Text type="primary" className={cx('second-column')} data-testid="schedule-slot-user-local-time">
|
||||
<Text type="primary" className={styles.secondColumn} data-testid="schedule-slot-user-local-time">
|
||||
User's local time
|
||||
<br />
|
||||
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')}
|
||||
|
|
@ -444,10 +449,10 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => {
|
|||
</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<div className={cx('details-icon')}>
|
||||
<Icon className={cx('icon')} name="arrows-h" />
|
||||
<div className={styles.detailsIcon}>
|
||||
<Icon className={styles.icon} name="arrows-h" />
|
||||
</div>
|
||||
<Text type="primary" className={cx('second-column')}>
|
||||
<Text type="primary" className={styles.secondColumn}>
|
||||
This shift
|
||||
<br />
|
||||
{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
|
||||
|
|
@ -488,13 +493,14 @@ interface ScheduleGapDetailsProps {
|
|||
}
|
||||
|
||||
const ScheduleGapDetails = observer((props: ScheduleGapDetailsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const {
|
||||
timezoneStore: { selectedTimezoneLabel, getDateInSelectedTimezone },
|
||||
} = useStore();
|
||||
const { event } = props;
|
||||
|
||||
return (
|
||||
<div className={cx('details')}>
|
||||
<div className={styles.details}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="sm">
|
||||
<VerticalGroup spacing="none">
|
||||
|
|
@ -507,3 +513,138 @@ const ScheduleGapDetails = observer((props: ScheduleGapDetailsProps) => {
|
|||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
root: css`
|
||||
height: 28px;
|
||||
background: ${COLORS.GRAY_8};
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
margin: 0 1px;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
`,
|
||||
|
||||
workingHours: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
`,
|
||||
|
||||
stack: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
|
||||
// TODO: What would be a matching value from theme for background?
|
||||
gap: css`
|
||||
background: rgba(209, 14, 92, 0.2);
|
||||
border: 1px dashed ${theme.colors.error.text};
|
||||
color: rgba(209, 14, 92, 0.5);
|
||||
visibility: hidden;
|
||||
`,
|
||||
|
||||
// TODO: Same here
|
||||
swap: css`
|
||||
border-radius: 10px;
|
||||
background: #ff99002e;
|
||||
height: 20px;
|
||||
`,
|
||||
|
||||
noUser: css`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: ${getLabelCss('blue', theme)};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`,
|
||||
|
||||
inactive: css`
|
||||
opacity: 0.3;
|
||||
`,
|
||||
|
||||
title: css`
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
label: css`
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
line-height: 16px;
|
||||
z-index: 1;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
|
||||
details: css`
|
||||
width: 300px;
|
||||
padding: 5px 0;
|
||||
`,
|
||||
|
||||
detailsUserStatus: css`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
|
||||
&--success {
|
||||
background-color: ${theme.colors.success.text};
|
||||
}
|
||||
`,
|
||||
|
||||
time: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: white;
|
||||
z-index: 2;
|
||||
`,
|
||||
|
||||
isOnCallIcon: css`
|
||||
color: ${theme.isDark ? '#181b1f' : '#fff'};
|
||||
vertical-align: middle;
|
||||
`,
|
||||
|
||||
detailsIcon: css`
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
`,
|
||||
|
||||
badge: css`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
`,
|
||||
|
||||
username: css`
|
||||
word-break: break-word;
|
||||
`,
|
||||
|
||||
secondColumn: css`
|
||||
width: 120px;
|
||||
`,
|
||||
|
||||
icon: css`
|
||||
color: ${theme.colors.secondary.text};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = observer((props
|
|||
|
||||
const { organizationStore } = store;
|
||||
const slackWorkspaceName =
|
||||
organizationStore.currentOrganization.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
|
||||
organizationStore.currentOrganization?.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
|
||||
|
||||
return (
|
||||
<div className={cx('root')} data-testid="schedule-user-details">
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@
|
|||
"editorMode": "code",
|
||||
"excludeNullMetadata": false,
|
||||
"exemplar": false,
|
||||
"expr": "sum(round(delta($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}[$__range]))) >= 0",
|
||||
"expr": "round(delta(sum($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"})[$__range:])) >= 0",
|
||||
"format": "time_series",
|
||||
"fullMetaSearch": false,
|
||||
"includeNullMetadata": true,
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "avg_over_time((sum($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}) / sum($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}))[$__range:])",
|
||||
"expr": "avg_over_time((sum($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}) / sum($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}))[$__range:])",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"range": false,
|
||||
|
|
@ -389,7 +389,7 @@
|
|||
"editorMode": "code",
|
||||
"excludeNullMetadata": false,
|
||||
"exemplar": false,
|
||||
"expr": "sum by (integration)(round(delta($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}[$__interval:]))) >= 0",
|
||||
"expr": "round(delta(sum by (integration) ($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"})[$__interval:])) >= 0",
|
||||
"fullMetaSearch": false,
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
|
|
@ -507,7 +507,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "avg(sum($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}) / sum($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}))",
|
||||
"expr": "avg(sum($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}) / sum($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}))",
|
||||
"instant": false,
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
|
|
@ -613,7 +613,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "sort_desc(sum by (integration)(round(delta($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}[$__range]))) >= 0)",
|
||||
"expr": "sort_desc(round(delta(sum by (integration)($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"})[$__range:])) >= 0)",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -729,7 +729,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "sort_desc(avg_over_time((sum by (integration)($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}) / sum by (integration)($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}))[$__range:]))",
|
||||
"expr": "sort_desc(avg_over_time((sum by (integration)($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}) / sum by (integration)($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}))[$__range:]))",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -862,7 +862,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "sort_desc(sum by (team)(round(delta($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}[$__range]))) >= 0)",
|
||||
"expr": "sort_desc(round(delta(sum by (team)($alert_groups_total{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"})[$__range:])) >= 0)",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -979,7 +979,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "sort_desc(avg_over_time((sum by(team) ($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}) / sum by(team)($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\"}))[$__range:]))",
|
||||
"expr": "sort_desc(avg_over_time((sum by(team) ($alert_groups_response_time_seconds_sum{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}) / sum by(team)($alert_groups_response_time_seconds_count{slug=~\"$instance\", team=~\"$team\", integration=~\"$integration\", service_name=~\"$service_name\"}))[$__range:]))",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -1129,7 +1129,7 @@
|
|||
"editorMode": "code",
|
||||
"excludeNullMetadata": false,
|
||||
"exemplar": false,
|
||||
"expr": "sum by (username)(round(delta($user_was_notified_of_alert_groups_total{slug=~\"$instance\"}[$__interval:]))) >= 0",
|
||||
"expr": "round(delta(sum by (username)($user_was_notified_of_alert_groups_total{slug=~\"$instance\"})[$__interval:])) >= 0",
|
||||
"fullMetaSearch": false,
|
||||
"instant": false,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -1222,7 +1222,7 @@
|
|||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": false,
|
||||
"expr": "sort_desc(sum by (username)(round(delta($user_was_notified_of_alert_groups_total{slug=~\"$instance\"}[$__range]))) >= 0)",
|
||||
"expr": "sort_desc(round(delta(sum by (username)($user_was_notified_of_alert_groups_total{slug=~\"$instance\"})[$__range:])) >= 0)",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
|
|
@ -1392,6 +1392,7 @@
|
|||
"type": "query"
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
|
|
@ -1419,6 +1420,7 @@
|
|||
"type": "query"
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
|
|
@ -1446,6 +1448,7 @@
|
|||
"type": "query"
|
||||
},
|
||||
{
|
||||
"allValue": ".+",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
|
|
@ -1471,6 +1474,35 @@
|
|||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"type": "query"
|
||||
},
|
||||
{
|
||||
"allValue": "($^)|(.+)",
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": ["All"],
|
||||
"value": ["$__all"]
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"definition": "label_values(${alert_groups_total}{slug=~\"$instance\", team=~\"$team\"},service_name)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "Service name",
|
||||
"multi": true,
|
||||
"name": "service_name",
|
||||
"options": [],
|
||||
"query": {
|
||||
"qryType": 1,
|
||||
"query": "label_values(${alert_groups_total}{slug=~\"$instance\", team=~\"$team\"},service_name)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"refresh": 2,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"type": "query"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export class AlertGroupStore {
|
|||
rootStore: RootStore;
|
||||
alerts = new Map<string, ApiSchemas['AlertGroup']>();
|
||||
bulkActions: any = [];
|
||||
silenceOptions: any;
|
||||
silenceOptions: Array<ApiSchemas['AlertGroupSilenceOptions']>;
|
||||
searchResult: { [key: string]: Array<ApiSchemas['AlertGroup']['pk']> } = {};
|
||||
incidentFilters: any;
|
||||
initialQuery = qs.parse(window.location.search);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export class EscalationChainStore extends BaseStore {
|
|||
try {
|
||||
escalationChain = await this.getById(id, skipErrorHandling);
|
||||
} catch (error) {
|
||||
if (error.response.data.error_code === 'wrong_team') {
|
||||
if (error.response.data?.error_code === 'wrong_team') {
|
||||
escalationChain = {
|
||||
id,
|
||||
name: '🔒 Private escalation chain',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
||||
export const splitToGroups = (labels: Array<ApiSchemas['LabelKey']> | Array<ApiSchemas['LabelValue']>) => {
|
||||
return labels.reduce(
|
||||
return labels?.reduce(
|
||||
(memo, option) => {
|
||||
memo.find(({ name }) => name === (option.prescribed ? 'System' : 'User added')).options.push(option);
|
||||
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export class ScheduleStore extends BaseStore {
|
|||
try {
|
||||
schedule = await this.getById(id, true, fromOrganization);
|
||||
} catch (error) {
|
||||
if (error.response.data.error_code === 'wrong_team') {
|
||||
if (error.response.data?.error_code === 'wrong_team') {
|
||||
schedule = {
|
||||
id,
|
||||
name: '🔒 Private schedule',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export class UserGroupStore extends BaseStore {
|
|||
searchResult: { [key: string]: Array<UserGroup['id']> } = {};
|
||||
|
||||
@observable.shallow
|
||||
items?: { [id: string]: UserGroup[] } = {};
|
||||
items?: { [id: string]: UserGroup } = {};
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
|
@ -46,6 +46,18 @@ export class UserGroupStore extends BaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async fetchItemById(id: string) {
|
||||
const item: UserGroup = await this.getById(id);
|
||||
|
||||
runInAction(() => {
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getSearchResult = (query = '') => {
|
||||
if (!this.searchResult[query]) {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export const Header = observer(() => {
|
|||
<>
|
||||
<div>
|
||||
<div className={cx('page-header__inner', { [styles.headerTopNavbar]: isTopNavbar() })}>
|
||||
<div className={cx(styles.navbarLeft)}>
|
||||
<div className={styles.navbarLeft}>
|
||||
<span className={cx('page-header__logo', styles.logoContainer)}>
|
||||
<img className={cx(styles.pageHeaderImage)} src={logo} alt="Grafana OnCall" />
|
||||
<img className={styles.pageHeaderImage} src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
<div className={cx('page-header__info-block')}>{renderHeading()}</div>
|
||||
</div>
|
||||
|
|
@ -37,18 +37,18 @@ export const Header = observer(() => {
|
|||
if (store.isOpenSource) {
|
||||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx(styles.pageHeaderTitle)}>Grafana OnCall</h1>
|
||||
<div className={cx(styles.navbarHeadingContainer)}>
|
||||
<h1 className={styles.pageHeaderTitle}>Grafana OnCall</h1>
|
||||
<div className={styles.navbarHeadingContainer}>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
|
||||
<Card heading={undefined} className={cx(styles.navbarHeading)}>
|
||||
<Card heading={undefined} className={styles.navbarHeading}>
|
||||
<a
|
||||
href="https://github.com/grafana/oncall"
|
||||
className={cx(styles.navbarLink)}
|
||||
className={styles.navbarLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={gitHubStarSVG} className={cx(styles.navbarStarIcon)} alt="" /> Star us on GitHub
|
||||
<img src={gitHubStarSVG} className={styles.navbarStarIcon} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -59,7 +59,7 @@ export const Header = observer(() => {
|
|||
return (
|
||||
<>
|
||||
<HorizontalGroup>
|
||||
<h1 className={cx(styles.pageHeaderTitle)}>Grafana OnCall</h1>
|
||||
<h1 className={styles.pageHeaderTitle}>Grafana OnCall</h1>
|
||||
</HorizontalGroup>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
</>
|
||||
|
|
@ -70,7 +70,7 @@ export const Header = observer(() => {
|
|||
const Banners: React.FC = () => {
|
||||
const styles = useStyles2(getHeaderStyles);
|
||||
return (
|
||||
<div className={cx(styles.banners)}>
|
||||
<div className={styles.banners}>
|
||||
<Alerts />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -823,7 +823,7 @@ export interface paths {
|
|||
cookie?: never;
|
||||
};
|
||||
/** @description Retrieve a list of valid silence options */
|
||||
get: operations['alertgroups_silence_options_retrieve'];
|
||||
get: operations['alertgroups_silence_options_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
|
|
@ -3765,7 +3765,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
alertgroups_silence_options_retrieve: {
|
||||
alertgroups_silence_options_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -3779,7 +3779,7 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AlertGroupSilenceOptions'];
|
||||
'application/json': components['schemas']['AlertGroupSilenceOptions'][];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin: 10px 20px;
|
||||
}
|
||||
|
||||
.escalations {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.new-escalation-chain {
|
||||
margin: 16px;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.left-column {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
border-right: var(--border);
|
||||
}
|
||||
|
||||
.escalations-list {
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.escalation {
|
||||
margin: 16px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getEscalationChainStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
filters: css`
|
||||
margin-bottom: 20px;
|
||||
`,
|
||||
|
||||
loading: css`
|
||||
margin: 10px 20px;
|
||||
`,
|
||||
|
||||
escalations: css`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
border-radius: 2px;
|
||||
`,
|
||||
|
||||
newEscalationChain: css`
|
||||
margin: 16px;
|
||||
width: calc(100% - 32px);
|
||||
`,
|
||||
|
||||
leftColumn: css`
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid ${theme.colors.border.weak};
|
||||
`,
|
||||
|
||||
escalationsList: css`
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
`,
|
||||
|
||||
escalation: css`
|
||||
margin: 16px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
`,
|
||||
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`,
|
||||
|
||||
list: css`
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
`,
|
||||
|
||||
buttons: css`
|
||||
padding-bottom: 24px;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Icon, IconButton, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, Tooltip, VerticalGroup, withTheme2 } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { Collapse } from 'components/Collapse/Collapse';
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
|
|
@ -30,11 +31,11 @@ import { withMobXProviderContext } from 'state/withStore';
|
|||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import styles from './EscalationChains.module.css';
|
||||
import { getEscalationChainStyles } from './EscalationChains.styles';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface EscalationChainsPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
|
||||
interface EscalationChainsPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
interface EscalationChainsPageState extends PageBaseState {
|
||||
modeToShowEscalationChainForm?: EscalationChainFormMode;
|
||||
|
|
@ -129,6 +130,7 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
match: {
|
||||
params: { id },
|
||||
},
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const { extraEscalationChains } = this.state;
|
||||
|
|
@ -143,6 +145,9 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
data = [...extraEscalationChains, ...searchResult];
|
||||
}
|
||||
|
||||
const styles = getEscalationChainStyles(theme);
|
||||
const utilStyles = getUtilStyles(theme);
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
|
|
@ -152,23 +157,23 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div>
|
||||
{this.renderFilters()}
|
||||
{!data || data.length ? (
|
||||
<div className={cx('escalations')}>
|
||||
<div className={cx('left-column')}>
|
||||
<div className={styles.escalations}>
|
||||
<div className={styles.leftColumn}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.setState({ modeToShowEscalationChainForm: EscalationChainFormMode.Create });
|
||||
}}
|
||||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
className={styles.newEscalationChain}
|
||||
>
|
||||
New escalation chain
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<div className={cx('escalations-list')} data-testid="escalation-chains-list">
|
||||
<div className={styles.escalationsList} data-testid="escalation-chains-list">
|
||||
{data ? (
|
||||
<GList
|
||||
autoScroll
|
||||
|
|
@ -181,14 +186,14 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
</GList>
|
||||
) : (
|
||||
<VerticalGroup>
|
||||
<Text type="primary" className={cx('loadingPlaceholder')}>
|
||||
<Text type="primary" className={utilStyles.loadingPlaceholder}>
|
||||
Loading...
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('escalation')}>{this.renderEscalation()}</div>
|
||||
<div className={styles.escalation}>{this.renderEscalation()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tutorial
|
||||
|
|
@ -234,9 +239,11 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
}
|
||||
|
||||
renderFilters() {
|
||||
const { query, store } = this.props;
|
||||
const { query, store, theme } = this.props;
|
||||
const styles = getEscalationChainStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('filters')}>
|
||||
<div className={styles.filters}>
|
||||
<RemoteFilters
|
||||
query={query}
|
||||
page={PAGE.Escalations}
|
||||
|
|
@ -286,7 +293,7 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
};
|
||||
|
||||
renderEscalation = () => {
|
||||
const { store } = this.props;
|
||||
const { store, theme } = this.props;
|
||||
const { selectedEscalationChain } = this.state;
|
||||
|
||||
const { escalationChainStore } = store;
|
||||
|
|
@ -297,14 +304,15 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
|
||||
const escalationChain = escalationChainStore.items[selectedEscalationChain];
|
||||
const escalationChainDetails = escalationChainStore.details[selectedEscalationChain];
|
||||
const styles = getEscalationChainStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Block withBackground className={cx('header')}>
|
||||
<Block withBackground className={styles.header}>
|
||||
<Text size="large" onTextChange={this.handleEscalationChainNameChange} data-testid="escalation-chain-name">
|
||||
{escalationChain.name}
|
||||
</Text>
|
||||
<div className={cx('buttons')}>
|
||||
<div className={styles.buttons}>
|
||||
<HorizontalGroup>
|
||||
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
|
||||
<IconButton
|
||||
|
|
@ -359,14 +367,14 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
isOpen
|
||||
>
|
||||
{escalationChainDetails.length ? (
|
||||
<ul className={cx('list')}>
|
||||
<ul className={styles.list}>
|
||||
{escalationChainDetails.map((alertReceiveChannel) => (
|
||||
<li key={alertReceiveChannel.id}>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<PluginLink query={{ page: 'integrations', id: alertReceiveChannel.id }}>
|
||||
{alertReceiveChannel.display_name}
|
||||
</PluginLink>
|
||||
<ul className={cx('list')}>
|
||||
<ul className={styles.list}>
|
||||
{alertReceiveChannel.channel_filters.map((channelFilter) => (
|
||||
<li key={channelFilter.id}>
|
||||
<Icon name="arrow-right" />
|
||||
|
|
@ -472,4 +480,4 @@ class _EscalationChainsPage extends React.Component<EscalationChainsPageProps, E
|
|||
};
|
||||
}
|
||||
|
||||
export const EscalationChainsPage = withRouter(withMobXProviderContext(_EscalationChainsPage));
|
||||
export const EscalationChainsPage = withRouter(withMobXProviderContext(withTheme2(_EscalationChainsPage)));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
|
|
@ -13,15 +14,15 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
|
|||
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
|
||||
import { move } from 'state/helpers';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { TEXT_ELLIPSIS_CLASS } from 'utils/consts';
|
||||
|
||||
import styles from './Incident.module.scss';
|
||||
export const IncidentRelatedUsers = (props: { incident: ApiSchemas['AlertGroup']; isFull: boolean }) => {
|
||||
const { incident, isFull } = props;
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export function renderRelatedUsers(incident: ApiSchemas['AlertGroup'], isFull = false) {
|
||||
const { related_users } = incident;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const utilStyles = useStyles2(getUtilStyles);
|
||||
|
||||
let users = [...related_users];
|
||||
|
||||
if (!users.length && isFull) {
|
||||
|
|
@ -39,10 +40,10 @@ export function renderRelatedUsers(incident: ApiSchemas['AlertGroup'], isFull =
|
|||
return (
|
||||
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }} wrap={false}>
|
||||
<TextEllipsisTooltip placement="top" content={user.username}>
|
||||
<Text type="secondary" className={cx(TEXT_ELLIPSIS_CLASS)}>
|
||||
<Text type="secondary" className={utilStyles.overflowChild}>
|
||||
<Avatar size="small" src={user.avatar} />{' '}
|
||||
<span className={cx('break-word', 'u-margin-right-xs')}>{user.username}</span>
|
||||
<span className={cx('user-badge')}>{badge}</span>
|
||||
<span className={cx(utilStyles.wordBreakAll, 'u-margin-right-xs')}>{user.username}</span>
|
||||
<span className={styles.userBadge}>{badge}</span>
|
||||
</Text>
|
||||
</TextEllipsisTooltip>
|
||||
</PluginLink>
|
||||
|
|
@ -67,12 +68,16 @@ export function renderRelatedUsers(incident: ApiSchemas['AlertGroup'], isFull =
|
|||
const otherUsers = isFull ? [] : users.slice(2);
|
||||
|
||||
if (isFull) {
|
||||
return visibleUsers.map((user, index) => (
|
||||
return (
|
||||
<>
|
||||
{index ? ', ' : ''}
|
||||
{renderUser(user)}
|
||||
{visibleUsers.map((user, index) => (
|
||||
<>
|
||||
{index ? ', ' : ''}
|
||||
{renderUser(user)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -101,7 +106,7 @@ export function renderRelatedUsers(incident: ApiSchemas['AlertGroup'], isFull =
|
|||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function getActionButtons(
|
||||
incident: ApiSchemas['AlertGroup'],
|
||||
|
|
@ -183,3 +188,11 @@ export function getActionButtons(
|
|||
|
||||
return <HorizontalGroup justify="flex-end">{buttons}</HorizontalGroup>;
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
userBadge: css`
|
||||
vertical-align: middle;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,209 +0,0 @@
|
|||
.incident-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.incident-row-left {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.payload-subtitle {
|
||||
margin-bottom: var(--title-marginBottom);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid rgba(204, 204, 220, 0.15);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 16px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message a {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message ul {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message code {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.image {
|
||||
margin-top: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.collapse {
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.column {
|
||||
width: 50%;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.column:not(:first-child) {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.incidents-content > div:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(204, 204, 220, 0.25);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.incidents-content > div:not(:first-child) {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style-type: none;
|
||||
margin: 0 0 24px 12px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
margin: 50px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-group-stub {
|
||||
margin: 24px auto;
|
||||
width: 520px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-group-stub-divider {
|
||||
width: 520px;
|
||||
}
|
||||
|
||||
.timeline-icon-background {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--timeline-icon-background);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background: var(--tag-border-primary);
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-filter {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: var(--secondary-text-color);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.integration-logo {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.label-button {
|
||||
padding: 0 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.label-button:disabled {
|
||||
border: var(--border-strong);
|
||||
}
|
||||
|
||||
.label-button-text {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-tag-container {
|
||||
margin-right: 8px;
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
height: 24px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.paged-users {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paged-users-list {
|
||||
list-style-type: none;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
& > li .trash-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > li:hover .trash-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& > li {
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
|
||||
& .hover-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > li:hover {
|
||||
background: var(--background-secondary);
|
||||
|
||||
& .hover-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useState, SyntheticEvent } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -15,14 +17,16 @@ import {
|
|||
Modal,
|
||||
Tooltip,
|
||||
Divider,
|
||||
withTheme2,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import Emoji from 'react-emoji-render';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import { COLORS, getLabelBackgroundTextColorObject } from 'styles/utils.styles';
|
||||
import { OnCallPluginExtensionPoints } from 'types';
|
||||
|
||||
import errorSVG from 'assets/img/error.svg';
|
||||
|
|
@ -36,6 +40,7 @@ import {
|
|||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { SourceCode } from 'components/SourceCode/SourceCode';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
|
||||
|
|
@ -49,7 +54,8 @@ import { AlertGroupHelper } from 'models/alertgroup/alertgroup.helpers';
|
|||
import { AlertAction, TimeLineItem, TimeLineRealm, GroupedAlert } from 'models/alertgroup/alertgroup.types';
|
||||
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown';
|
||||
import { CUSTOM_SILENCE_VALUE, IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown';
|
||||
import { IncidentSilenceModal } from 'pages/incidents/parts/IncidentSilenceModal';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
|
@ -61,18 +67,19 @@ import { parseURL } from 'utils/url';
|
|||
import { openNotification } from 'utils/utils';
|
||||
|
||||
import { getActionButtons } from './Incident.helpers';
|
||||
import styles from './Incident.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
const INTEGRATION_NAME_LENGTH_LIMIT = 30;
|
||||
|
||||
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
|
||||
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
interface IncidentPageState extends PageBaseState {
|
||||
showIntegrationSettings?: boolean;
|
||||
showAttachIncidentForm?: boolean;
|
||||
timelineFilter: string;
|
||||
resolutionNoteText: string;
|
||||
silenceModalData: { incident: ApiSchemas['AlertGroup'] };
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -81,6 +88,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
timelineFilter: 'all',
|
||||
resolutionNoteText: '',
|
||||
errorData: initErrorDataState(),
|
||||
silenceModalData: undefined,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -127,9 +135,10 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
},
|
||||
} = this.props;
|
||||
|
||||
const { errorData, showIntegrationSettings, showAttachIncidentForm } = this.state;
|
||||
const { errorData, showIntegrationSettings, showAttachIncidentForm, silenceModalData } = this.state;
|
||||
const { isNotFoundError, isWrongTeamError, isUnknownError } = errorData;
|
||||
const { alerts } = store.alertGroupStore;
|
||||
const styles = getStyles(this.props.theme);
|
||||
|
||||
const incident = alerts.get(id);
|
||||
|
||||
|
|
@ -143,7 +152,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
onUnacknowledge: this.getOnActionButtonClick(id, AlertAction.unAcknowledge),
|
||||
onUnresolve: this.getOnActionButtonClick(id, AlertAction.unResolve),
|
||||
onAcknowledge: this.getOnActionButtonClick(id, AlertAction.Acknowledge),
|
||||
onSilence: this.getSilenceClickHandler(id),
|
||||
onSilence: this.getSilenceClickHandler(incident),
|
||||
onUnsilence: this.getUnsilenceClickHandler(id),
|
||||
},
|
||||
true
|
||||
|
|
@ -154,7 +163,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
|
||||
if (!incident && !isNotFoundError && !isWrongTeamError) {
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div>
|
||||
<LoadingPlaceholder text="Loading Alert Group..." />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -163,9 +172,9 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
return (
|
||||
<PageErrorHandlingWrapper errorData={errorData} objectName="alert group" pageName="incidents">
|
||||
{() => (
|
||||
<div className={cx('root')}>
|
||||
<div>
|
||||
{errorData.isNotFoundError ? (
|
||||
<div className={cx('not-found')}>
|
||||
<div className={styles.notFound}>
|
||||
<VerticalGroup spacing="lg" align="center">
|
||||
<Text.Title level={1}>404</Text.Title>
|
||||
<Text.Title level={4}>Alert group not found</Text.Title>
|
||||
|
|
@ -179,8 +188,8 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
) : (
|
||||
<>
|
||||
{this.renderHeader()}
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('column')}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.column}>
|
||||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList
|
||||
id={incident.pk}
|
||||
|
|
@ -188,7 +197,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
/>
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>
|
||||
<div className={styles.column}>
|
||||
<VerticalGroup style={{ display: 'block' }}>
|
||||
{(!incident.resolved || incident?.paged_users?.length > 0) && (
|
||||
<AddResponders
|
||||
|
|
@ -241,6 +250,23 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal where users can input their custom duration for silencing an alert group */}
|
||||
<RenderConditionally
|
||||
shouldRender={Boolean(silenceModalData?.incident)}
|
||||
render={() => (
|
||||
<IncidentSilenceModal
|
||||
alertGroupID={silenceModalData.incident.pk}
|
||||
alertGroupName={silenceModalData.incident.render_for_web?.title}
|
||||
isOpen
|
||||
onDismiss={() => this.setState({ silenceModalData: undefined })}
|
||||
onSave={(duration: number) => {
|
||||
this.setState({ silenceModalData: undefined });
|
||||
store.alertGroupStore.doIncidentAction(silenceModalData.incident.pk, AlertAction.Silence, duration);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageErrorHandlingWrapper>
|
||||
|
|
@ -270,21 +296,24 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
},
|
||||
} = this.props;
|
||||
const { alerts } = store.alertGroupStore;
|
||||
const styles = getStyles(this.props.theme);
|
||||
|
||||
const incident = alerts.get(id);
|
||||
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
|
||||
store.alertReceiveChannelStore,
|
||||
incident.alert_receive_channel
|
||||
);
|
||||
|
||||
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
|
||||
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
|
||||
const sourceLink = incident?.render_for_web?.source_link;
|
||||
const isServiceNow = Boolean(incident?.external_urls?.find((el) => el.integration_type === INTEGRATION_SERVICENOW));
|
||||
|
||||
return (
|
||||
<Block className={cx('block')}>
|
||||
<Block className={styles.block}>
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup className={cx('title')}>
|
||||
<HorizontalGroup>
|
||||
<PluginLink query={{ page: 'alert-groups', ...query }}>
|
||||
<IconButton aria-label="Go Back" name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
|
|
@ -316,11 +345,11 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
name="code-branch"
|
||||
onClick={this.showAttachIncidentForm}
|
||||
tooltip="Attach to another Alert Group"
|
||||
className={cx('title-icon')}
|
||||
className={styles.titleIcon}
|
||||
/>
|
||||
)}
|
||||
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
|
||||
<IconButton name="slack" tooltip="View in Slack" className={cx('title-icon')} />
|
||||
<IconButton name="slack" tooltip="View in Slack" className={styles.titleIcon} />
|
||||
</a>
|
||||
<CopyToClipboard
|
||||
text={window.location.href}
|
||||
|
|
@ -328,21 +357,21 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
openNotification('Link copied');
|
||||
}}
|
||||
>
|
||||
<IconButton name="copy" tooltip="Copy link" className={cx('title-icon')} />
|
||||
<IconButton name="copy" tooltip="Copy link" className={styles.titleIcon} />
|
||||
</CopyToClipboard>
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<div className={cx('info-row')}>
|
||||
<div className={styles.infoRow}>
|
||||
<HorizontalGroup>
|
||||
<div className={cx('status-tag-container')}>
|
||||
<div className={styles.statusTagContainer}>
|
||||
<IncidentDropdown
|
||||
alert={incident}
|
||||
onResolve={this.getOnActionButtonClick(incident.pk, AlertAction.Resolve)}
|
||||
onUnacknowledge={this.getOnActionButtonClick(incident.pk, AlertAction.unAcknowledge)}
|
||||
onUnresolve={this.getOnActionButtonClick(incident.pk, AlertAction.unResolve)}
|
||||
onAcknowledge={this.getOnActionButtonClick(incident.pk, AlertAction.Acknowledge)}
|
||||
onSilence={this.getSilenceClickHandler(incident.pk)}
|
||||
onSilence={this.getSilenceClickHandler(incident)}
|
||||
onUnsilence={this.getUnsilenceClickHandler(incident.pk)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -374,7 +403,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
className={cx('label-button')}
|
||||
className={styles.labelButton}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
|
|
@ -384,18 +413,18 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
: 'Go to Integration'
|
||||
}
|
||||
>
|
||||
<div className={cx('label-button-text', 'source-name')}>
|
||||
<div className={cx('integration-logo')}>
|
||||
<div className={cx(styles.labelButtonText, styles.sourceName)}>
|
||||
<div className={styles.integrationLogo}>
|
||||
<IntegrationLogo integration={integration} scale={0.08} />
|
||||
</div>
|
||||
<div className={cx('label-button-text')}>{integrationNameWithEmojies}</div>
|
||||
<div className={styles.labelButtonText}>{integrationNameWithEmojies}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</PluginLink>
|
||||
|
||||
{isServiceNow && (
|
||||
<Button variant="secondary" fill="outline" size="sm" className={cx('label-button')}>
|
||||
<Button variant="secondary" fill="outline" size="sm" className={styles.labelButton}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Icon name="exchange-alt" />
|
||||
<span>Service Now</span>
|
||||
|
|
@ -419,7 +448,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
fill="outline"
|
||||
size="sm"
|
||||
disabled={sourceLink === null || parseURL(sourceLink) === ''}
|
||||
className={cx('label-button')}
|
||||
className={styles.labelButton}
|
||||
icon="external-link-alt"
|
||||
>
|
||||
Source
|
||||
|
|
@ -430,14 +459,14 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<HorizontalGroup justify="space-between" className={cx('buttons-row')}>
|
||||
<HorizontalGroup justify="space-between" className={styles.buttonsRow}>
|
||||
<HorizontalGroup>
|
||||
{getActionButtons(incident, {
|
||||
onResolve: this.getOnActionButtonClick(incident.pk, AlertAction.Resolve),
|
||||
onUnacknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.unAcknowledge),
|
||||
onUnresolve: this.getOnActionButtonClick(incident.pk, AlertAction.unResolve),
|
||||
onAcknowledge: this.getOnActionButtonClick(incident.pk, AlertAction.Acknowledge),
|
||||
onSilence: this.getSilenceClickHandler(incident.pk),
|
||||
onSilence: this.getSilenceClickHandler(incident),
|
||||
onUnsilence: this.getUnsilenceClickHandler(incident.pk),
|
||||
})}
|
||||
<ExtensionLinkDropdown
|
||||
|
|
@ -495,8 +524,10 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
match: {
|
||||
params: { id },
|
||||
},
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const styles = getStyles(theme);
|
||||
const incident = store.alertGroupStore.alerts.get(id);
|
||||
|
||||
if (!incident.render_after_resolve_report_json) {
|
||||
|
|
@ -508,11 +539,11 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
const isResolutionNoteTextEmpty = resolutionNoteText === '';
|
||||
return (
|
||||
<Block bordered>
|
||||
<Text.Title type="primary" level={4} className={cx('timeline-title')}>
|
||||
<Text.Title type="primary" level={4} className={styles.timelineTitle}>
|
||||
Timeline
|
||||
</Text.Title>
|
||||
<RadioButtonGroup
|
||||
className={cx('timeline-filter')}
|
||||
className={styles.timelineFilter}
|
||||
options={[
|
||||
{ label: 'Show full timeline', value: 'all' },
|
||||
{ label: 'Resolution notes only', value: TimeLineRealm.ResolutionNote },
|
||||
|
|
@ -522,11 +553,15 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
this.setState({ timelineFilter: value });
|
||||
}}
|
||||
/>
|
||||
<ul className={cx('timeline')} data-testid="incident-timeline-list">
|
||||
<ul className={styles.timeline} data-testid="incident-timeline-list">
|
||||
{timeline.map((item: TimeLineItem, idx: number) => (
|
||||
<li key={idx} className={cx('timeline-item')}>
|
||||
<li key={idx} className={styles.timelineItem}>
|
||||
<HorizontalGroup align="flex-start">
|
||||
<div className={cx('timeline-icon-background', { blue: item.realm === TimeLineRealm.ResolutionNote })}>
|
||||
<div
|
||||
className={cx(styles.timelineIconBackground, {
|
||||
blue: item.realm === TimeLineRealm.ResolutionNote,
|
||||
})}
|
||||
>
|
||||
{this.renderTimelineItemIcon(item.realm)}
|
||||
</div>
|
||||
<VerticalGroup spacing="none">
|
||||
|
|
@ -632,11 +667,15 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
};
|
||||
};
|
||||
|
||||
getSilenceClickHandler = (incidentId: ApiSchemas['AlertGroup']['pk']) => {
|
||||
getSilenceClickHandler = (incident: ApiSchemas['AlertGroup']) => {
|
||||
const { store } = this.props;
|
||||
|
||||
return (value: number) => {
|
||||
return store.alertGroupStore.doIncidentAction(incidentId, AlertAction.Silence, value);
|
||||
return (value: number): Promise<void> => {
|
||||
if (value === CUSTOM_SILENCE_VALUE) {
|
||||
this.setState({ silenceModalData: { incident } });
|
||||
return Promise.resolve(); // awaited by other component
|
||||
}
|
||||
return store.alertGroupStore.doIncidentAction(incident.pk, AlertAction.Silence, value);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -662,16 +701,17 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
|
|||
}
|
||||
|
||||
function Incident({ incident }: { incident: ApiSchemas['AlertGroup']; datetimeReference: string }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div key={incident.pk} className={cx('incident')}>
|
||||
<div key={incident.pk}>
|
||||
<div
|
||||
className={cx('message')}
|
||||
className={styles.message}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(incident.render_for_web.message),
|
||||
}}
|
||||
data-testid="incident-message"
|
||||
/>
|
||||
{incident.render_for_web.image_url && <img className={cx('image')} src={incident.render_for_web.image_url} />}
|
||||
{incident.render_for_web.image_url && <img className={styles.image} src={incident.render_for_web.image_url} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -685,6 +725,7 @@ function GroupedIncidentsList({
|
|||
}) {
|
||||
const store = useStore();
|
||||
const incident = store.alertGroupStore.alerts.get(id);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const alerts = incident.alerts;
|
||||
if (!alerts) {
|
||||
|
|
@ -697,7 +738,7 @@ function GroupedIncidentsList({
|
|||
return (
|
||||
<Collapse
|
||||
headerWithBackground
|
||||
className={cx('collapse')}
|
||||
className={styles.collapse}
|
||||
isOpen={false}
|
||||
label={
|
||||
<HorizontalGroup wrap>
|
||||
|
|
@ -706,7 +747,7 @@ function GroupedIncidentsList({
|
|||
<Text type="secondary">{latestAlertMoment.format('MMM DD, YYYY HH:mm:ss Z').toString()}</Text>
|
||||
</HorizontalGroup>
|
||||
}
|
||||
contentClassName={cx('incidents-content')}
|
||||
contentClassName={styles.incidentsContent}
|
||||
>
|
||||
{alerts.map((alert) => (
|
||||
<GroupedIncident key={alert.id} incident={alert} datetimeReference={getIncidentDatetimeReference(alert)} />
|
||||
|
|
@ -719,12 +760,13 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
const [incidentRawResponse, setIncidentRawResponse] = useState<{ id: string; raw_request_data: any }>(undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const payloadJSON = isModalOpen ? JSON.stringify(incidentRawResponse.raw_request_data, null, 4) : undefined;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isModalOpen && (
|
||||
<Modal onDismiss={() => setIsModalOpen(false)} closeOnEscape isOpen={isModalOpen} title="Alert Payload">
|
||||
<div className={cx('payload-subtitle')}>
|
||||
<div className={styles.payloadSubtitle}>
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">
|
||||
{incident.render_for_web.title} - {datetimeReference}
|
||||
|
|
@ -740,7 +782,7 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
openNotification('Copied!');
|
||||
}}
|
||||
>
|
||||
<Button className={cx('button')} variant="primary" icon="copy">
|
||||
<Button variant="primary" icon="copy">
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
|
|
@ -750,8 +792,8 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
)}
|
||||
|
||||
<div key={incident.id}>
|
||||
<div className={cx('incident-row')}>
|
||||
<div className={cx('incident-row-left')}>
|
||||
<div className={styles.incidentRow}>
|
||||
<div className={styles.incidentRowLeftSide}>
|
||||
<HorizontalGroup wrap justify={'flex-start'}>
|
||||
<Text.Title type="secondary" level={4}>
|
||||
{incident.render_for_web.title}
|
||||
|
|
@ -759,7 +801,7 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
<Text type="secondary">{datetimeReference}</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('incident-row-right')}>
|
||||
<div>
|
||||
<HorizontalGroup wrap={false} justify={'flex-end'}>
|
||||
<Tooltip placement="top" content="Alert Payload">
|
||||
<IconButton aria-label="Alert Payload" name="arrow" onClick={() => openIncidentResponse(incident)} />
|
||||
|
|
@ -769,13 +811,13 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
</div>
|
||||
<Text type="secondary">
|
||||
<div
|
||||
className={cx('message')}
|
||||
className={styles.message}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(incident.render_for_web.message),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{incident.render_for_web.image_url && <img className={cx('image')} src={incident.render_for_web.image_url} />}
|
||||
{incident.render_for_web.image_url && <img className={styles.image} src={incident.render_for_web.image_url} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -795,6 +837,7 @@ function AttachedIncidentsList({
|
|||
getUnattachClickHandler(pk: string): void;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
const incident = store.alertGroupStore.alerts.get(id);
|
||||
|
||||
if (!incident.dependent_alert_groups.length) {
|
||||
|
|
@ -806,10 +849,10 @@ function AttachedIncidentsList({
|
|||
return (
|
||||
<Collapse
|
||||
headerWithBackground
|
||||
className={cx('collapse')}
|
||||
className={styles.collapse}
|
||||
isOpen
|
||||
label={<HorizontalGroup wrap>{incident.dependent_alert_groups.length} Attached Alert Groups</HorizontalGroup>}
|
||||
contentClassName={cx('incidents-content')}
|
||||
contentClassName={styles.incidentsContent}
|
||||
>
|
||||
{alerts.map((incident) => {
|
||||
return (
|
||||
|
|
@ -830,8 +873,9 @@ function AttachedIncidentsList({
|
|||
}
|
||||
|
||||
const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={cx('alert-group-stub')}>
|
||||
<div className={styles.alertGroupStub}>
|
||||
<VerticalGroup align="center" spacing="md">
|
||||
<img src={errorSVG} alt="" />
|
||||
<Text.Title level={3}>An unexpected error happened</Text.Title>
|
||||
|
|
@ -839,7 +883,7 @@ const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => {
|
|||
OnCall is not able to receive any information about the current Alert Group. It's unknown if it's firing,
|
||||
acknowledged, silenced, or resolved.
|
||||
</Text>
|
||||
<div className={cx('alert-group-stub-divider')}>
|
||||
<div className={styles.alertGroupStubDivider}>
|
||||
<Divider />
|
||||
</div>
|
||||
<Text type="secondary">Meanwhile, you could try changing the status of this Alert Group:</Text>
|
||||
|
|
@ -851,4 +895,221 @@ const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const IncidentPage = withRouter(withMobXProviderContext(_IncidentPage));
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
incidentRow: css`
|
||||
display: flex;
|
||||
`,
|
||||
|
||||
incidentRowLeftSide: css`
|
||||
flex-grow: 1;
|
||||
`,
|
||||
|
||||
block: css`
|
||||
padding: 0 0 20px 0;
|
||||
`,
|
||||
|
||||
payloadSubtitle: css`
|
||||
margin-bottom: 16px;
|
||||
`,
|
||||
|
||||
infoRow: css`
|
||||
width: 100%;
|
||||
border-bottom: 1px solid ${theme.colors.border.medium};
|
||||
padding-bottom: 20px;
|
||||
`,
|
||||
|
||||
buttonsRow: css`
|
||||
margin-top: 20px;
|
||||
`,
|
||||
|
||||
content: css`
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
`,
|
||||
|
||||
timelineIconBackground: css`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(${theme.isDark ? '70, 76, 84, 1' : '70, 76, 84, 0'});
|
||||
`,
|
||||
|
||||
message: css`
|
||||
margin-top: 16px;
|
||||
word-wrap: break-word;
|
||||
|
||||
a {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
`,
|
||||
|
||||
image: css`
|
||||
margin-top: 16px;
|
||||
max-width: 100%;
|
||||
`,
|
||||
|
||||
collapse: css`
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
column: css`
|
||||
width: 50%;
|
||||
padding-right: 24px;
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-left: 24px;
|
||||
}
|
||||
`,
|
||||
|
||||
incidentsContent: css`
|
||||
> div:not(:last-child) {
|
||||
border-bottom: 1px solid ${COLORS.BORDER};
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
> div:not(:first-child) {
|
||||
padding-top: 16px;
|
||||
}
|
||||
`,
|
||||
|
||||
timeline: css`
|
||||
list-style-type: none;
|
||||
margin: 0 0 24px 12px;
|
||||
`,
|
||||
|
||||
timelineItem: css`
|
||||
margin-top: 12px;
|
||||
`,
|
||||
|
||||
notFound: css`
|
||||
margin: 50px auto;
|
||||
text-align: center;
|
||||
`,
|
||||
|
||||
alertGroupStub: css`
|
||||
margin: 24px auto;
|
||||
width: 520px;
|
||||
text-align: center;
|
||||
`,
|
||||
|
||||
alertGroupStubDivider: css`
|
||||
width: 520px;
|
||||
`,
|
||||
|
||||
blue: css`
|
||||
background: ${getLabelBackgroundTextColorObject('blue', theme).sourceColor};
|
||||
`,
|
||||
|
||||
timelineTitle: css`
|
||||
margin-bottom: 24px;
|
||||
`,
|
||||
|
||||
timelineFilter: css`
|
||||
margin-bottom: 24px;
|
||||
`,
|
||||
|
||||
titleIcon: css`
|
||||
color: ${theme.colors.secondary.text};
|
||||
margin-left: 4px;
|
||||
`,
|
||||
|
||||
integrationLogo: css`
|
||||
margin-right: 8px;
|
||||
`,
|
||||
|
||||
labelButton: css`
|
||||
padding: 0 8px;
|
||||
font-weight: 400;
|
||||
|
||||
&:disabled {
|
||||
border: 1px solid ${theme.colors.border.strong};
|
||||
}
|
||||
`,
|
||||
|
||||
labelButtonText: css`
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
sourceName: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`,
|
||||
|
||||
statusTagContainer: css`
|
||||
margin-right: 8px;
|
||||
display: inherit;
|
||||
`,
|
||||
|
||||
statusTag: css`
|
||||
height: 24px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 2px;
|
||||
`,
|
||||
|
||||
pagedUsers: css`
|
||||
width: 100%;
|
||||
`,
|
||||
|
||||
// TODO: Where are trash-button/hover-button coming from?
|
||||
pagedUsersList: css`
|
||||
list-style-type: none;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
& > li .trash-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > li:hover .trash-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& > li {
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
|
||||
& .hover-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > li:hover {
|
||||
background: ${theme.colors.background.secondary};
|
||||
|
||||
& .hover-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
userBadge: css`
|
||||
vertical-align: middle;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export const IncidentPage = withRouter(withMobXProviderContext(withTheme2(_IncidentPage)));
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
.select {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fields-dropdown {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.above-incidents-table {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.horizontal-scroll-table table td:global(.rc-table-cell) {
|
||||
white-space: nowrap;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.bulk-actions-container {
|
||||
margin: 10px 0 10px 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-actions-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.other-users {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 24px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.btn-results {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* filter cards */
|
||||
|
||||
.cards {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
display: block;
|
||||
flex: 0 0 25%;
|
||||
max-width: 25%;
|
||||
}
|
||||
|
||||
.loadingPlaceholder {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.col {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.col {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -8,17 +9,16 @@ import {
|
|||
Icon,
|
||||
LoadingPlaceholder,
|
||||
RadioButtonGroup,
|
||||
Themeable2,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import moment from 'moment-timezone';
|
||||
import Emoji from 'react-emoji-render';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { CardButton } from 'components/CardButton/CardButton';
|
||||
import { CursorPagination } from 'components/CursorPagination/CursorPagination';
|
||||
|
|
@ -48,8 +48,9 @@ import {
|
|||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { LoaderHelper } from 'models/loader/loader.helpers';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { IncidentRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { LocationHelper } from 'utils/LocationHelper';
|
||||
|
|
@ -58,18 +59,17 @@ import { INCIDENT_HORIZONTAL_SCROLLING_STORAGE, PAGE, PLUGIN_ROOT, TEXT_ELLIPSIS
|
|||
import { getItem, setItem } from 'utils/localStorage';
|
||||
import { TableColumn } from 'utils/types';
|
||||
|
||||
import styles from './Incidents.module.scss';
|
||||
import { IncidentDropdown } from './parts/IncidentDropdown';
|
||||
import { SilenceButtonCascader } from './parts/SilenceButtonCascader';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface Pagination {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface IncidentsPageProps extends WithStoreProps, PageProps, RouteComponentProps, Themeable2 {}
|
||||
interface IncidentsPageProps extends WithStoreProps, PageProps, RouteComponentProps {
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
interface IncidentsPageState {
|
||||
selectedIncidentIds: Array<ApiSchemas['AlertGroup']['pk']>;
|
||||
|
|
@ -164,14 +164,17 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
render() {
|
||||
const { history } = this.props;
|
||||
const { showAddAlertGroupForm } = this.state;
|
||||
|
||||
const {
|
||||
theme,
|
||||
store: { alertReceiveChannelStore },
|
||||
} = this.props;
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('title')}>
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text.Title level={3}>Alert Groups</Text.Title>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
|
||||
|
|
@ -184,6 +187,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
{this.renderIncidentFilters()}
|
||||
{this.renderTable()}
|
||||
</div>
|
||||
|
||||
{showAddAlertGroupForm && (
|
||||
<ManualAlertGroup
|
||||
onHide={() => {
|
||||
|
|
@ -199,15 +203,16 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
);
|
||||
}
|
||||
|
||||
renderCards(filtersState, setFiltersState, filtersOnFiltersValueChange, store) {
|
||||
renderCards(filtersState, setFiltersState, filtersOnFiltersValueChange, store: RootStore, theme: GrafanaTheme2) {
|
||||
const { values } = filtersState;
|
||||
const { stats } = store.alertGroupStore;
|
||||
|
||||
const status = values.status || [];
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('cards', 'row')}>
|
||||
<div key="new" className={cx('col')}>
|
||||
<div className={cx(styles.cards, styles.row)}>
|
||||
<div key="new" className={styles.col}>
|
||||
<CardButton
|
||||
icon={<Icon name="bell" size="xxl" />}
|
||||
description="Firing"
|
||||
|
|
@ -221,7 +226,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div key="acknowledged" className={cx('col')}>
|
||||
<div key="acknowledged" className={styles.col}>
|
||||
<CardButton
|
||||
icon={<Icon name="eye" size="xxl" />}
|
||||
description="Acknowledged"
|
||||
|
|
@ -235,7 +240,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div key="resolved" className={cx('col')}>
|
||||
<div key="resolved" className={styles.col}>
|
||||
<CardButton
|
||||
icon={<Icon name="check" size="xxl" />}
|
||||
description="Resolved"
|
||||
|
|
@ -249,7 +254,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div key="silenced" className={cx('col')}>
|
||||
<div key="silenced" className={styles.col}>
|
||||
<CardButton
|
||||
icon={<Icon name="bell-slash" size="xxl" />}
|
||||
description="Silenced"
|
||||
|
|
@ -306,15 +311,17 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
};
|
||||
|
||||
renderIncidentFilters() {
|
||||
const { query, store } = this.props;
|
||||
const { query, store, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('filters')}>
|
||||
<div className={styles.filters}>
|
||||
<RemoteFilters
|
||||
query={query}
|
||||
page={PAGE.Incidents}
|
||||
onChange={this.handleFiltersChange}
|
||||
extraFilters={(...args) => {
|
||||
return this.renderCards(...args, store);
|
||||
return this.renderCards(...args, store, theme);
|
||||
}}
|
||||
grafanaTeamStore={store.grafanaTeamStore}
|
||||
defaultFilters={{
|
||||
|
|
@ -423,7 +430,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
|
||||
renderBulkActions = () => {
|
||||
const { selectedIncidentIds, affectedRows, isHorizontalScrolling } = this.state;
|
||||
const { store } = this.props;
|
||||
const { store, theme } = this.props;
|
||||
|
||||
if (!store.alertGroupStore.bulkActions) {
|
||||
return null;
|
||||
|
|
@ -438,10 +445,12 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
Object.keys(affectedRows).length
|
||||
);
|
||||
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={cx('above-incidents-table')}>
|
||||
<div className={cx('bulk-actions-container')}>
|
||||
<div className={cx('bulk-actions-list')}>
|
||||
<div className={styles.aboveIncidentsTable}>
|
||||
<div className={styles.bulkActionsContainer}>
|
||||
<div className={styles.bulkActionsList}>
|
||||
{'resolve' in store.alertGroupStore.bulkActions && (
|
||||
<WithPermissionControlTooltip key="resolve" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button
|
||||
|
|
@ -490,18 +499,18 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={cx('fields-dropdown')}>
|
||||
<div className={styles.fieldsDropdown}>
|
||||
<RenderConditionally shouldRender={!isLoading && hasInvalidatedAlert}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Text type="secondary">Results out of date</Text>
|
||||
<Button className={cx('btn-results')} variant="primary" onClick={this.onIncidentsUpdateClick}>
|
||||
<Button className={styles.btnResults} variant="primary" onClick={this.onIncidentsUpdateClick}>
|
||||
Refresh
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</RenderConditionally>
|
||||
|
||||
<RenderConditionally shouldRender={isLoading}>
|
||||
<LoadingPlaceholder text="Loading..." className={cx('loadingPlaceholder')} />
|
||||
<LoadingPlaceholder text="Loading..." className={styles.loadingPlaceholder} />
|
||||
</RenderConditionally>
|
||||
|
||||
<RenderConditionally shouldRender={store.hasFeature(AppFeature.Labels)}>
|
||||
|
|
@ -521,11 +530,14 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
renderTable() {
|
||||
const { selectedIncidentIds, pagination, isHorizontalScrolling } = this.state;
|
||||
const { alertGroupStore, filtersStore, loaderStore } = this.props.store;
|
||||
const { theme } = this.props;
|
||||
|
||||
const { results, prev, next } = AlertGroupHelper.getAlertSearchResult(alertGroupStore);
|
||||
const isLoading =
|
||||
LoaderHelper.isLoading(loaderStore, ActionKey.FETCH_INCIDENTS) || filtersStore.options['incidents'] === undefined;
|
||||
|
||||
const styles = getStyles(theme);
|
||||
|
||||
if (results && !results.length) {
|
||||
return (
|
||||
<Tutorial
|
||||
|
|
@ -550,7 +562,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
const tableColumns = this.getTableColumns();
|
||||
|
||||
return (
|
||||
<div className={cx('root')} ref={this.rootElRef}>
|
||||
<div ref={this.rootElRef}>
|
||||
{this.renderBulkActions()}
|
||||
<GTable
|
||||
emptyText={isLoading ? 'Loading...' : 'No alert groups found'}
|
||||
|
|
@ -566,7 +578,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
scroll={{ x: isHorizontalScrolling ? 'max-content' : undefined }}
|
||||
/>
|
||||
{this.shouldShowPagination() && (
|
||||
<div className={cx('pagination')}>
|
||||
<div className={styles.pagination}>
|
||||
<CursorPagination
|
||||
current={`${pagination.start}-${pagination.end}`}
|
||||
itemsPerPage={alertGroupStore.alertsSearchResult?.page_size}
|
||||
|
|
@ -582,15 +594,16 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
);
|
||||
}
|
||||
|
||||
renderId(record: ApiSchemas['AlertGroup']) {
|
||||
renderId = (record: ApiSchemas['AlertGroup']) => {
|
||||
const styles = getUtilStyles(this.props.theme);
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={`#${record.inside_organization_number}`}>
|
||||
<Text type="secondary" className={cx(TEXT_ELLIPSIS_CLASS, 'overflow-child--line-1')}>
|
||||
<Text type="secondary" className={cx(styles.overflowChild)}>
|
||||
#{record.inside_organization_number}
|
||||
</Text>
|
||||
</TextEllipsisTooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderTitle = (record: ApiSchemas['AlertGroup']) => {
|
||||
const { store, query } = this.props;
|
||||
|
|
@ -600,7 +613,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
return (
|
||||
<div>
|
||||
<TextEllipsisTooltip placement="top" content={record.render_for_web.title}>
|
||||
<Text type="link" size="medium" className={cx('overflow-parent')} data-testid="integration-url">
|
||||
<Text type="link" size="medium" data-testid="integration-url">
|
||||
<PluginLink
|
||||
query={{
|
||||
page: 'alert-groups',
|
||||
|
|
@ -626,16 +639,18 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
|
||||
renderSource = (record: ApiSchemas['AlertGroup']) => {
|
||||
const {
|
||||
theme,
|
||||
store: { alertReceiveChannelStore },
|
||||
} = this.props;
|
||||
const integration = AlertReceiveChannelHelper.getIntegrationSelectOption(
|
||||
alertReceiveChannelStore,
|
||||
record.alert_receive_channel
|
||||
);
|
||||
const utilStyles = getUtilStyles(theme);
|
||||
|
||||
return (
|
||||
<TextEllipsisTooltip
|
||||
className={cx('u-flex', 'u-flex-gap-xs', 'overflow-parent')}
|
||||
className={cx(utilStyles.flex, utilStyles.flexGapXS)}
|
||||
placement="top"
|
||||
content={record?.alert_receive_channel?.verbal_name || ''}
|
||||
>
|
||||
|
|
@ -830,7 +845,9 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
Users: {
|
||||
title: 'Users',
|
||||
key: 'users',
|
||||
render: renderRelatedUsers,
|
||||
render: (item: ApiSchemas['AlertGroup'], isFull: boolean) => (
|
||||
<IncidentRelatedUsers incident={item} isFull={isFull} />
|
||||
),
|
||||
grow: 1.5,
|
||||
},
|
||||
};
|
||||
|
|
@ -996,4 +1013,119 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
}
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
select: css`
|
||||
width: 400px;
|
||||
`,
|
||||
|
||||
bau: css`
|
||||
${[1, 2, 3].map(
|
||||
(num) => `
|
||||
$--line-${num} {
|
||||
-webkit-line-clamp: ${num}
|
||||
}
|
||||
`
|
||||
)}
|
||||
`,
|
||||
|
||||
actionButtons: css`
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
`,
|
||||
|
||||
filters: css`
|
||||
margin-bottom: 20px;
|
||||
`,
|
||||
|
||||
fieldsDropdown: css`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
`,
|
||||
|
||||
aboveIncidentsTable: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`,
|
||||
|
||||
horizontalScrollTable: css`
|
||||
table td:global(.rc-table-cell) {
|
||||
white-space: nowrap;
|
||||
padding-right: 16px;
|
||||
}
|
||||
`,
|
||||
|
||||
bulkActionsContainer: css`
|
||||
margin: 10px 0 10px 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`,
|
||||
|
||||
bulkActionsList: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`,
|
||||
|
||||
otherUsers: css`
|
||||
color: ${theme.colors.secondary.text};
|
||||
`,
|
||||
|
||||
pagination: css`
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
`,
|
||||
|
||||
title: css`
|
||||
margin-bottom: 24px;
|
||||
right: 0;
|
||||
`,
|
||||
|
||||
btnResults: css`
|
||||
margin-left: 8px;
|
||||
`,
|
||||
|
||||
/* filter cards */
|
||||
|
||||
cards: css`
|
||||
margin-top: 25px;
|
||||
`,
|
||||
|
||||
row: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
row-gap: 16px;
|
||||
`,
|
||||
|
||||
loadingPlaceholder: css`
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
`,
|
||||
|
||||
col: css`
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
display: block;
|
||||
flex: 0 0 25%;
|
||||
max-width: 25%;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export const IncidentsPage = withRouter(withMobXProviderContext(withTheme2(_IncidentsPage)));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { FC, SyntheticEvent, useRef, useState } from 'react';
|
||||
|
||||
import { cx } from '@emotion/css';
|
||||
import { Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||
import { intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||
import { Icon, LoadingPlaceholder, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { Tag, TagColor } from 'components/Tag/Tag';
|
||||
|
|
@ -13,6 +14,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
|
|||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import { getIncidentDropdownStyles } from './IncidentDropdown.styles';
|
||||
import { IncidentSilenceModal } from './IncidentSilenceModal';
|
||||
import { SilenceSelect } from './SilenceSelect';
|
||||
|
||||
const getIncidentTagColor = (alert: ApiSchemas['AlertGroup']) => {
|
||||
|
|
@ -41,7 +43,7 @@ function IncidentStatusTag({
|
|||
return (
|
||||
<Tag
|
||||
forwardedRef={forwardedRef}
|
||||
className={cx(styles.incidentTag)}
|
||||
className={styles.incidentTag}
|
||||
color={getIncidentTagColor(alert)}
|
||||
onClick={() => {
|
||||
const boundingRect = forwardedRef.current.getBoundingClientRect();
|
||||
|
|
@ -50,11 +52,13 @@ function IncidentStatusTag({
|
|||
}}
|
||||
>
|
||||
<Text size="small">{IncidentStatus[alert.status]}</Text>
|
||||
<Icon className={cx(styles.incidentIcon)} name="angle-down" size="sm" />
|
||||
<Icon className={styles.incidentIcon} name="angle-down" size="sm" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export const CUSTOM_SILENCE_VALUE = -100;
|
||||
|
||||
export const IncidentDropdown: FC<{
|
||||
alert: ApiSchemas['AlertGroup'];
|
||||
onResolve: (e: SyntheticEvent) => Promise<void>;
|
||||
|
|
@ -67,6 +71,7 @@ export const IncidentDropdown: FC<{
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentLoadingAction, setCurrentActionLoading] = useState<IncidentStatus>(undefined);
|
||||
const [forcedOpenAction, setForcedOpenAction] = useState<string>(undefined);
|
||||
const [isSilenceModalOpen, setIsSilenceModalOpen] = useState(false);
|
||||
|
||||
const styles = useStyles2(getIncidentDropdownStyles);
|
||||
const utilStyles = useStyles2(getUtilStyles);
|
||||
|
|
@ -99,12 +104,12 @@ export const IncidentDropdown: FC<{
|
|||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Resolve, onUnresolve, IncidentStatus.Firing)}
|
||||
>
|
||||
Firing{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -126,12 +131,12 @@ export const IncidentDropdown: FC<{
|
|||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onUnacknowledge, IncidentStatus.Firing)}
|
||||
>
|
||||
Unacknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -139,12 +144,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -160,60 +165,79 @@ export const IncidentDropdown: FC<{
|
|||
|
||||
if (alert.status === IncidentStatus.Firing) {
|
||||
return (
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.unResolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
<>
|
||||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.unResolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
|
||||
<div className={styles.incidentOptionItem}>
|
||||
<SilenceSelect
|
||||
customValueNum={CUSTOM_SILENCE_VALUE}
|
||||
placeholder={
|
||||
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
|
||||
}
|
||||
onSelect={async (value) => {
|
||||
if (value === CUSTOM_SILENCE_VALUE) {
|
||||
return setIsSilenceModalOpen(true);
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setForcedOpenAction(AlertAction.unResolve);
|
||||
setCurrentActionLoading(IncidentStatus.Silenced);
|
||||
|
||||
await onSilence(value);
|
||||
|
||||
setIsLoading(false);
|
||||
setForcedOpenAction(undefined);
|
||||
setCurrentActionLoading(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
|
||||
<div className={cx(styles.incidentOptionItem)}>
|
||||
<SilenceSelect
|
||||
placeholder={
|
||||
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
|
||||
}
|
||||
onSelect={async (value) => {
|
||||
setIsLoading(true);
|
||||
setForcedOpenAction(AlertAction.unResolve);
|
||||
setCurrentActionLoading(IncidentStatus.Silenced);
|
||||
|
||||
await onSilence(value);
|
||||
|
||||
setIsLoading(false);
|
||||
setForcedOpenAction(undefined);
|
||||
setCurrentActionLoading(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
|
||||
</WithContextMenu>
|
||||
<IncidentSilenceModal
|
||||
alertGroupID={alert.pk}
|
||||
alertGroupName={alert.render_for_web?.title}
|
||||
isOpen={isSilenceModalOpen}
|
||||
onDismiss={() => setIsSilenceModalOpen(false)}
|
||||
onSave={async (value) => {
|
||||
setIsSilenceModalOpen(false);
|
||||
setIsLoading(true);
|
||||
await onSilence(value);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -225,12 +249,12 @@ export const IncidentDropdown: FC<{
|
|||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onUnsilence, IncidentStatus.Firing)}
|
||||
>
|
||||
Unsilence{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -238,12 +262,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -251,12 +275,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
className={styles.incidentOptionItem}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<span className={styles.incidentOptionEl}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -265,7 +289,27 @@ export const IncidentDropdown: FC<{
|
|||
</div>
|
||||
)}
|
||||
>
|
||||
{({ openMenu }) => <IncidentStatusTag alert={alert} openMenu={openMenu} />}
|
||||
{({ openMenu }) => (
|
||||
<Tooltip content={getSilencedTooltip(alert)} placement={'bottom'}>
|
||||
<span>
|
||||
<IncidentStatusTag alert={alert} openMenu={openMenu} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</WithContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
function getSilencedTooltip(alert: ApiSchemas['AlertGroup']) {
|
||||
if (alert.silenced_until === null) {
|
||||
return `Silenced forever`;
|
||||
}
|
||||
return `Silence ends in ${getSilencedUntilInDuration(alert.silenced_until)}`;
|
||||
}
|
||||
|
||||
function getSilencedUntilInDuration(date: string) {
|
||||
return intervalToAbbreviatedDurationString({
|
||||
start: new Date(),
|
||||
end: new Date(date),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import {
|
||||
DateTime,
|
||||
addDurationToDate,
|
||||
dateTime,
|
||||
durationToMilliseconds,
|
||||
intervalToAbbreviatedDurationString,
|
||||
isValidDuration,
|
||||
parseDuration,
|
||||
} from '@grafana/data';
|
||||
import { Button, DateTimePicker, Field, HorizontalGroup, Input, Modal, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
||||
interface IncidentSilenceModalProps {
|
||||
isOpen: boolean;
|
||||
alertGroupID: string;
|
||||
alertGroupName: string;
|
||||
|
||||
onDismiss: () => void;
|
||||
onSave: (value: number) => void;
|
||||
}
|
||||
|
||||
const IncidentSilenceModal: React.FC<IncidentSilenceModalProps> = ({
|
||||
isOpen,
|
||||
alertGroupID,
|
||||
alertGroupName,
|
||||
|
||||
onDismiss,
|
||||
onSave,
|
||||
}) => {
|
||||
const [date, setDate] = useState<DateTime>(dateTime('2021-05-05 12:00:00'));
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const debouncedUpdateDateTime = useDebouncedCallback(updateDateTime, 500);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const isDurationValid = isValidDuration(duration);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onDismiss={onDismiss}
|
||||
closeOnBackdropClick={false}
|
||||
isOpen={isOpen}
|
||||
title={`Silence alert group #${alertGroupID} ${alertGroupName}`}
|
||||
className={styles.root}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<Field key={'SilencePicker'} label={'Silence End'} className={styles.containerChild}>
|
||||
<DateTimePicker label="Date" date={date} onChange={onDateChange} minDate={new Date()} />
|
||||
</Field>
|
||||
|
||||
<Field key={'Duration'} label={'Duration'} className={styles.containerChild} invalid={!isDurationValid}>
|
||||
<Input value={duration} onChange={onDurationChange} placeholder="Enter duration (2h 30m)" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant={'secondary'} onClick={onDismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={'primary'} onClick={onSubmit} disabled={!isDurationValid}>
|
||||
Add
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
function onDateChange(date: DateTime) {
|
||||
setDate(date);
|
||||
const duration = intervalToAbbreviatedDurationString({
|
||||
start: new Date(),
|
||||
end: new Date(date.toDate()),
|
||||
});
|
||||
setDuration(duration);
|
||||
}
|
||||
|
||||
function onDurationChange(event: React.SyntheticEvent<HTMLInputElement>) {
|
||||
const newDuration = event.currentTarget.value;
|
||||
if (newDuration !== duration) {
|
||||
setDuration(newDuration);
|
||||
debouncedUpdateDateTime(newDuration);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDateTime(newDuration: string) {
|
||||
setDate(dateTime(addDurationToDate(new Date(), parseDuration(newDuration))));
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
onSave(durationToMilliseconds(parseDuration(duration)) / 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
root: css`
|
||||
width: 600px;
|
||||
`,
|
||||
|
||||
container: css`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
column-gap: 16px;
|
||||
`,
|
||||
containerChild: css`
|
||||
flex-grow: 1;
|
||||
`,
|
||||
});
|
||||
|
||||
export { IncidentSilenceModal };
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ButtonCascader, ComponentSize } from '@grafana/ui';
|
||||
import { ButtonCascader, CascaderOption, ComponentSize } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
|
|
@ -8,6 +8,8 @@ import { SelectOption } from 'state/types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import { CUSTOM_SILENCE_VALUE } from './IncidentDropdown';
|
||||
|
||||
interface SilenceButtonCascaderProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -38,10 +40,15 @@ export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps
|
|||
</WithPermissionControlTooltip>
|
||||
);
|
||||
|
||||
function getOptions() {
|
||||
return silenceOptions.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}));
|
||||
function getOptions(): CascaderOption[] {
|
||||
return silenceOptions
|
||||
.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}))
|
||||
.concat({
|
||||
value: CUSTOM_SILENCE_VALUE,
|
||||
label: 'Custom',
|
||||
}) as CascaderOption[];
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import { UserActions } from 'utils/authorization/authorization';
|
|||
|
||||
interface SilenceSelectProps {
|
||||
placeholder?: string;
|
||||
customValueNum: number;
|
||||
|
||||
onSelect: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SilenceSelect = observer((props: SilenceSelectProps) => {
|
||||
const { placeholder = 'Silence for', onSelect } = props;
|
||||
const { customValueNum, placeholder = 'Silence for', onSelect } = props;
|
||||
|
||||
const store = useStore();
|
||||
|
||||
|
|
@ -24,21 +25,31 @@ export const SilenceSelect = observer((props: SilenceSelectProps) => {
|
|||
const silenceOptions = alertGroupStore.silenceOptions || [];
|
||||
|
||||
return (
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
placeholder={placeholder}
|
||||
value={undefined}
|
||||
onChange={({ value }) => onSelect(Number(value))}
|
||||
options={getOptions()}
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
<>
|
||||
{' '}
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
placeholder={placeholder}
|
||||
value={undefined}
|
||||
onChange={({ value }) => {
|
||||
onSelect(Number(value));
|
||||
}}
|
||||
options={getOptions()}
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
function getOptions() {
|
||||
return silenceOptions.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}));
|
||||
return silenceOptions
|
||||
.map((silenceOption: SelectOption) => ({
|
||||
value: silenceOption.value,
|
||||
label: silenceOption.display_name,
|
||||
}))
|
||||
.concat({
|
||||
value: customValueNum,
|
||||
label: 'Custom',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function getAlertGroupsByIntegrationScene({ datasource, stack }: Insights
|
|||
{
|
||||
editorMode: 'code',
|
||||
exemplar: false,
|
||||
expr: `sort_desc(sum by (integration)(round(delta($alert_groups_total{slug=~"${stack}", team=~"$team", integration=~"$integration"}[$__range]))) >= 0)`,
|
||||
expr: `sort_desc(round(delta(sum by (integration)($alert_groups_total{slug=~"${stack}", team=~"$team", integration=~"$integration", service_name=~"$service_name"})[$__range:])) >= 0)`,
|
||||
format: 'table',
|
||||
instant: true,
|
||||
legendFormat: '__auto',
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue