Merge pull request #4414 from grafana/dev

v1.5.2
This commit is contained in:
Matias Bordese 2024-05-28 15:54:28 -03:00 committed by GitHub
commit 4f05568007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 2579 additions and 1819 deletions

View file

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

View 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
View 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 }}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -638,6 +638,7 @@ class AlertGroupView(
choices=[display_name for _, display_name in AlertGroup.SILENCE_DELAY_OPTIONS]
),
},
many=True,
)
)
@action(methods=["get"], detail=False)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')},
),
]

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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};
}
`,
};
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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