commit
f21589c216
91 changed files with 1829 additions and 780 deletions
|
|
@ -69,7 +69,8 @@ steps:
|
|||
commands:
|
||||
- apt-get update && apt-get install -y netcat-traditional
|
||||
- cd engine/
|
||||
- pip install -r requirements.txt -r requirements-dev.txt
|
||||
- pip install uv
|
||||
- uv pip install --system -r requirements.txt -r requirements-dev.txt
|
||||
- ./wait_for_test_mysql_start.sh && pytest
|
||||
depends_on:
|
||||
- rabbit_test
|
||||
|
|
@ -385,6 +386,6 @@ name: cloud_access_policy_token
|
|||
|
||||
---
|
||||
kind: signature
|
||||
hmac: d541ed21fc2472272c6772e246aaf1a2606db112b4e72a44bc4530831e9ca4d3
|
||||
hmac: a045d72f3f3510895da049f4bf8f5ae4ac21f3ffa3d24cda047152c286df5bc2
|
||||
|
||||
...
|
||||
|
|
|
|||
26
.github/workflows/e2e-tests.yml
vendored
26
.github/workflows/e2e-tests.yml
vendored
|
|
@ -122,16 +122,19 @@ jobs:
|
|||
- name: Install Playwright deps
|
||||
uses: docker://mcr.microsoft.com/playwright:next-jammy
|
||||
|
||||
# Go and Mage are required to run gops-labels
|
||||
# ---------- Expensive e2e tests steps start -----------
|
||||
- name: Install Go
|
||||
if: inputs.run-expensive-tests
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.21.5"
|
||||
|
||||
- name: Install Mage
|
||||
if: inputs.run-expensive-tests
|
||||
run: go install github.com/magefile/mage@v1.15.0
|
||||
|
||||
- name: Get Vault secrets
|
||||
if: inputs.run-expensive-tests
|
||||
id: get-secrets
|
||||
uses: grafana/shared-workflows/actions/get-vault-secrets@main
|
||||
with:
|
||||
|
|
@ -141,6 +144,7 @@ jobs:
|
|||
GH_APP_PRIVATE_KEY=github-app:private-key
|
||||
|
||||
- name: Generate Github App token
|
||||
if: inputs.run-expensive-tests
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
|
|
@ -150,22 +154,13 @@ jobs:
|
|||
repositories: "ops-devenv,gops-labels"
|
||||
|
||||
- name: Clone other repos needed for cross-plugin e2e tests
|
||||
if: inputs.run-expensive-tests
|
||||
shell: bash
|
||||
run: |
|
||||
cd ..
|
||||
git clone https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/grafana/ops-devenv.git
|
||||
git clone https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/grafana/gops-labels.git
|
||||
|
||||
- name: Tilt CI - standard E2E tests
|
||||
shell: bash
|
||||
if: ${{ inputs.run-expensive-tests == false }}
|
||||
env:
|
||||
GRAFANA_VERSION: ${{ inputs.grafana_version }}
|
||||
GRAFANA_ADMIN_USERNAME: "irm"
|
||||
GRAFANA_ADMIN_PASSWORD: "irm"
|
||||
BROWSERS: ${{ inputs.browsers }}
|
||||
run: cd ../ops-devenv && tilt ci gops-labels oncall
|
||||
|
||||
- name: Tilt CI - standard and expensive E2E tests
|
||||
if: inputs.run-expensive-tests
|
||||
shell: bash
|
||||
|
|
@ -182,6 +177,15 @@ jobs:
|
|||
TWILIO_PHONE_NUMBER: '"${{ secrets.TWILIO_PHONE_NUMBER }}"'
|
||||
TWILIO_VERIFY_SID: ${{ secrets.TWILIO_VERIFY_SID }}
|
||||
run: cd ../ops-devenv && tilt ci gops-labels oncall
|
||||
# ---------- Expensive e2e tests steps end -----------
|
||||
|
||||
- name: Tilt CI - standard E2E tests
|
||||
shell: bash
|
||||
if: ${{ inputs.run-expensive-tests == false }}
|
||||
env:
|
||||
GRAFANA_VERSION: ${{ inputs.grafana_version }}
|
||||
BROWSERS: ${{ inputs.browsers }}
|
||||
run: tilt ci
|
||||
|
||||
- name: Setup Pages
|
||||
if: failure()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
name: Daily e2e tests
|
||||
name: Expensive e2e tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# allows manual run on github actions
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
|
|
@ -7,7 +10,7 @@ on:
|
|||
|
||||
jobs:
|
||||
end-to-end-tests:
|
||||
name: End to end tests
|
||||
name: Expensive e2e tests
|
||||
strategy:
|
||||
matrix:
|
||||
grafana_version:
|
||||
|
|
@ -41,7 +44,7 @@ jobs:
|
|||
post-status-to-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: end-to-end-tests
|
||||
if: always()
|
||||
if: failure
|
||||
steps:
|
||||
# Useful references
|
||||
# https://stackoverflow.com/questions/59073850/github-actions-get-url-of-test-build
|
||||
28
.github/workflows/linting-and-tests.yml
vendored
28
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Linting and Unit/e2e Tests
|
||||
name: Linting and Tests
|
||||
|
||||
"on":
|
||||
push:
|
||||
|
|
@ -127,8 +127,8 @@ jobs:
|
|||
# makemigrations --check = Exit with a non-zero status if model changes are missing migrations
|
||||
# and don't actually write them.
|
||||
run: |
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt requirements-dev.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
python manage.py makemigrations --check
|
||||
python manage.py lintmigrations
|
||||
|
||||
|
|
@ -185,8 +185,8 @@ jobs:
|
|||
working-directory: engine
|
||||
run: |
|
||||
apt-get update && apt-get install -y netcat-traditional
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt requirements-dev.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
./wait_for_test_mysql_start.sh && pytest -x
|
||||
|
||||
unit-test-backend-postgresql-rabbitmq:
|
||||
|
|
@ -235,8 +235,8 @@ jobs:
|
|||
- name: Unit Test Backend
|
||||
working-directory: engine
|
||||
run: |
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt requirements-dev.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
pytest -x
|
||||
|
||||
unit-test-backend-sqlite-redis:
|
||||
|
|
@ -275,8 +275,8 @@ jobs:
|
|||
working-directory: engine
|
||||
run: |
|
||||
apt-get update && apt-get install -y netcat-traditional
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt requirements-dev.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
pytest -x
|
||||
|
||||
unit-test-pd-migrator:
|
||||
|
|
@ -292,8 +292,8 @@ jobs:
|
|||
- name: Unit Test PD Migrator
|
||||
working-directory: tools/pagerduty-migrator
|
||||
run: |
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt
|
||||
pytest -x
|
||||
|
||||
mypy:
|
||||
|
|
@ -311,12 +311,12 @@ jobs:
|
|||
- name: mypy Static Type Checking
|
||||
working-directory: engine
|
||||
run: |
|
||||
pip install pip-tools
|
||||
pip-sync requirements.txt requirements-dev.txt
|
||||
pip install uv
|
||||
uv pip sync --system requirements.txt requirements-dev.txt
|
||||
mypy .
|
||||
|
||||
end-to-end-tests:
|
||||
name: End to end tests
|
||||
name: Standard e2e tests
|
||||
uses: ./.github/workflows/e2e-tests.yml
|
||||
with:
|
||||
# TODO: fix issues with running e2e tests against Grafana v10.2.x and v10.3.x
|
||||
|
|
|
|||
12
Makefile
12
Makefile
|
|
@ -248,21 +248,21 @@ endef
|
|||
|
||||
backend-bootstrap:
|
||||
python3.11 -m venv $(VENV_DIR)
|
||||
$(VENV_DIR)/bin/pip install -U pip wheel pip-tools
|
||||
$(VENV_DIR)/bin/pip-sync $(REQUIREMENTS_TXT) $(REQUIREMENTS_DEV_TXT)
|
||||
$(VENV_DIR)/bin/pip install -U pip wheel uv
|
||||
$(VENV_DIR)/bin/uv pip sync $(REQUIREMENTS_TXT) $(REQUIREMENTS_DEV_TXT)
|
||||
@if [ -f $(REQUIREMENTS_ENTERPRISE_TXT) ]; then \
|
||||
$(VENV_DIR)/bin/pip install -r $(REQUIREMENTS_ENTERPRISE_TXT); \
|
||||
$(VENV_DIR)/bin/uv pip install -r $(REQUIREMENTS_ENTERPRISE_TXT); \
|
||||
fi
|
||||
|
||||
backend-migrate:
|
||||
$(call backend_command,python manage.py migrate)
|
||||
|
||||
backend-compile-deps:
|
||||
pip-compile --strip-extras $(REQUIREMENTS_IN)
|
||||
pip-compile --strip-extras $(REQUIREMENTS_DEV_IN)
|
||||
uv pip compile --strip-extras $(REQUIREMENTS_IN)
|
||||
uv pip compile --strip-extras $(REQUIREMENTS_DEV_IN)
|
||||
|
||||
backend-upgrade-deps:
|
||||
pip-compile --strip-extras --upgrade $(REQUIREMENTS_IN)
|
||||
uv pip compile --strip-extras --upgrade $(REQUIREMENTS_IN)
|
||||
|
||||
run-backend-server:
|
||||
$(call backend_command,python manage.py runserver 0.0.0.0:8080)
|
||||
|
|
|
|||
32
Tiltfile
32
Tiltfile
|
|
@ -59,13 +59,27 @@ docker_build_sub(
|
|||
],
|
||||
)
|
||||
|
||||
# Build the plugin in the background
|
||||
local_resource(
|
||||
"build-ui",
|
||||
labels=["OnCallUI"],
|
||||
serve_cmd="cd grafana-plugin && yarn watch",
|
||||
allow_parallel=True,
|
||||
)
|
||||
# On CI dependencies are installed separately so we just build prod bundle to be consumed by Grafana dev server
|
||||
if is_ci:
|
||||
local_resource(
|
||||
"build-ui",
|
||||
labels=["OnCallUI"],
|
||||
dir="grafana-plugin",
|
||||
cmd="yarn build",
|
||||
allow_parallel=True,
|
||||
)
|
||||
|
||||
# Locally we install dependencies and we run watch mode
|
||||
if not is_ci:
|
||||
local_resource(
|
||||
"build-ui",
|
||||
labels=["OnCallUI"],
|
||||
dir="grafana-plugin",
|
||||
cmd="yarn install",
|
||||
serve_dir="grafana-plugin",
|
||||
serve_cmd="yarn watch",
|
||||
allow_parallel=True,
|
||||
)
|
||||
|
||||
local_resource(
|
||||
"e2e-tests",
|
||||
|
|
@ -130,7 +144,7 @@ configmap_create(
|
|||
k8s_resource(
|
||||
objects=["grafana-oncall-app-provisioning:configmap"],
|
||||
new_name="grafana-oncall-app-provisioning-configmap",
|
||||
resource_deps=["build-ui", "engine"],
|
||||
resource_deps=["build-ui"],
|
||||
labels=["Grafana"],
|
||||
)
|
||||
|
||||
|
|
@ -141,7 +155,7 @@ if not running_under_parent_tiltfile:
|
|||
context="grafana-plugin",
|
||||
plugin_files=["grafana-plugin/src/plugin.json"],
|
||||
namespace="default",
|
||||
deps=["grafana-oncall-app-provisioning-configmap", "build-ui", "engine"],
|
||||
deps=["grafana-oncall-app-provisioning-configmap", "build-ui"],
|
||||
extra_env={
|
||||
"GF_SECURITY_ADMIN_PASSWORD": "oncall",
|
||||
"GF_SECURITY_ADMIN_USER": "oncall",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Related: [How to develop integrations](/engine/config_integrations/README.md)
|
|||
- [Tilt | Kubernetes for Prod, Tilt for Dev](https://tilt.dev/)
|
||||
- [tilt-dev/ctlptl: Making local Kubernetes clusters fun and easy to set up](https://github.com/tilt-dev/ctlptl)
|
||||
- [Kind](https://kind.sigs.k8s.io)
|
||||
- [Node.js v18.x](https://nodejs.org/en/download)
|
||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
|
||||
### Launch the environment
|
||||
|
|
|
|||
|
|
@ -243,6 +243,16 @@ must match the structure of how the fields are nested in the data.
|
|||
"urgency": "3"
|
||||
}
|
||||
},
|
||||
"alert_group_acknowledged_by": {
|
||||
"id": "UVMX6YI9VY9PV",
|
||||
"username": "admin",
|
||||
"email": "admin@localhost"
|
||||
},
|
||||
"alert_group_resolved_by": {
|
||||
"id": "UVMX6YI9VY9PV",
|
||||
"username": "admin",
|
||||
"email": "admin@localhost"
|
||||
},
|
||||
"notified_users": [],
|
||||
"users_to_be_notified": [],
|
||||
"responses": {
|
||||
|
|
@ -335,6 +345,22 @@ first element being the user that will be notified next. Like `notified_users` d
|
|||
a user in this array may have already been notified by the time this data is being processed. Access as
|
||||
`{{ users_to_notify[0].username }}` for example.
|
||||
|
||||
#### `alert_group_acknowledged_by`
|
||||
|
||||
Information about the user who acknowledged the alert group
|
||||
|
||||
- `{{ user.id }}` - [UID](#uid) of the user within Grafana OnCall
|
||||
- `{{ user.username }}` - Username in Grafana
|
||||
- `{{ user.email }}` - Email associated with user's Grafana account
|
||||
|
||||
#### `alert_group_resolved_by`
|
||||
|
||||
Information about the user who resolved the alert group
|
||||
|
||||
- `{{ user.id }}` - [UID](#uid) of the user within Grafana OnCall
|
||||
- `{{ user.username }}` - Username in Grafana
|
||||
- `{{ user.email }}` - Email associated with user's Grafana account
|
||||
|
||||
#### `responses`
|
||||
|
||||
The responses field is used to access the response data of other webhooks that are associated with this alert group.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
aliases:
|
||||
- servicenow/
|
||||
- /docs/oncall/latest/integrations/available-integrations/configure-servicenow/
|
||||
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-servicenow/
|
||||
title: ServiceNow integration for Grafana OnCall
|
||||
menuTitle: ServiceNow
|
||||
description: Learn how to configure the ServiceNow integration for Grafana OnCall
|
||||
weight: 500
|
||||
keywords:
|
||||
- Grafana Cloud
|
||||
- Alerts
|
||||
|
|
@ -10,62 +10,83 @@ keywords:
|
|||
- on-call
|
||||
- webhooks
|
||||
- ServiceNow
|
||||
canonical: https://grafana.com/docs/oncall/latest/integrations/servicenow
|
||||
aliases:
|
||||
- /docs/grafana-cloud/alerting-and-irm/oncall/integrations/servicenow
|
||||
- /docs/oncall/latest/integrations/servicenow/
|
||||
- /servicenow/
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
title: ServiceNow
|
||||
weight: 500
|
||||
---
|
||||
|
||||
# Integrate Grafana OnCall with ServiceNow
|
||||
# ServiceNow integration for Grafana OnCall
|
||||
|
||||
> This integration is not available in OSS version
|
||||
{{< admonition type="note" >}}
|
||||
This integration is available exclusively on Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
The bi-directional ServiceNow integration can create and update incidents in ServiceNow based on Grafana OnCall alert
|
||||
groups, and vice-versa. This integration supports alerts originating from ServiceNow or other integrations such as
|
||||
Alertmanager, Grafana Alerting, and others.
|
||||
Integrate ServiceNow with Grafana OnCall for bidirectional functionality that automatically creates and updates incidents in ServiceNow based on Grafana OnCall alert
|
||||
groups, and vice versa. Whether your alerts originate from ServiceNow or another integration like
|
||||
Alertmanager or Grafana Alerting, you can manage updates and status changes directly from ServiceNow.
|
||||
|
||||
The integration can automatically:
|
||||
Use this integration to automate the following processes:
|
||||
|
||||
* Create an incident in ServiceNow when an alert group is created in OnCall.
|
||||
* Update the incident state in ServiceNow when the alert group status changes in OnCall.
|
||||
* Create an alert group in OnCall when an incident is created in ServiceNow.
|
||||
* Update the alert group status in OnCall when the incident state changes in ServiceNow.
|
||||
|
||||
## Prerequisites
|
||||
## Before you begin
|
||||
|
||||
1. Create a new ServiceNow user to be used by Grafana OnCall. On your ServiceNow instance,
|
||||
navigate to **User Administration** > **Users** and click **New**. Fill in the following details:
|
||||
Before configuring the integration, ensure you or your ServiceNow Admin have created a Service account specifically for Grafana OnCall integration.
|
||||
|
||||
Follow these steps to create a ServiceNow user for Grafana OnCall:
|
||||
|
||||
1. In ServiceNow,navigate to **User Administration** > **Users** and click **New**.
|
||||
1. Fill in the following details:
|
||||
* Username: `grafana-oncall`
|
||||
* First name: `Grafana OnCall`
|
||||
* Active: ✔
|
||||
* Web service access only: ✔
|
||||
1. After creating the user, generate a password using the **Set Password** button. Securely store the password for later use.
|
||||
1. Navigate to the **Roles** tab and grant the following roles to the user:
|
||||
* `itil` (for incident creation and updates)
|
||||
* `personalize_choices` (to fetch the list of available incident states)
|
||||
|
||||
After creating the user, generate and save a password using the **Set Password** button for later use.
|
||||
2. Grant the following roles to the user (use the **Roles** tab):
|
||||
* `itil` (allows creating and updating incidents)
|
||||
* `personalize_choices` (allows fetching the list of available incident states)
|
||||
## Configure ServiceNow integration
|
||||
|
||||
## Create integration
|
||||
### Create integration
|
||||
|
||||
1. On the **Integrations** tab, click **+ New integration**.
|
||||
2. Select **ServiceNow** from the list of available integrations.
|
||||
3. Enter a name and description for the integration.
|
||||
4. Enter ServiceNow credentials (instance URL, username, and password of the [Grafana OnCall user](#prerequisites)) and verify the connection.
|
||||
5. Make sure **Create default outgoing webhooks** is enabled. This will create the necessary webhooks in Grafana OnCall
|
||||
to send alerts to ServiceNow.
|
||||
6. Click **Create integration**.
|
||||
7. Map ServiceNow incident states to OnCall alert group statuses. Example:
|
||||
* `Firing -> New`
|
||||
* `Acknowledged -> In Progress`
|
||||
* `Resolved -> Resolved`
|
||||
* `Silenced -> Not Selected`
|
||||
8. Generate a ServiceNow Business Rule script and copy it to your clipboard. This script will allow your ServiceNow
|
||||
instance to send updates to Grafana OnCall. You won't be able to see the script again after closing the
|
||||
dialog, but you can regenerate it at any time in integration settings. See the next step for more details on how to
|
||||
create a Business Rule in ServiceNow using the generated script.
|
||||
9. On your ServiceNow instance, navigate to **System Definition** > **Business Rules** and click **New**.
|
||||
Fill in the following details:
|
||||
1. On the **Integrations** tab in Grafana OnCall, click **+ New integration**.
|
||||
1. Select **ServiceNow** from the list of available integrations.
|
||||
1. Enter a name and description for the integration.
|
||||
1. Enter ServiceNow credentials (instance URL, username, and password of the [Grafana OnCall user](#before-you-begin)) and verify the connection.
|
||||
1. Ensure **Create default outgoing webhooks** is enabled to create necessary webhooks in Grafana OnCall for sending alerts to ServiceNow.
|
||||
1. Click **Create integration**.
|
||||
|
||||
### Map incident states
|
||||
|
||||
Map ServiceNow incident states to OnCall alert group statuses.
|
||||
|
||||
Example:
|
||||
|
||||
* `Firing -> New`
|
||||
* `Acknowledged -> In Progress`
|
||||
* `Resolved -> Resolved`
|
||||
* `Silenced -> Not Selected`
|
||||
|
||||
### Generate Business Rule script
|
||||
|
||||
Generate a ServiceNow Business Rule script to enable your ServiceNow instance to send updates to Grafana OnCall.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
You can't view the script again after closing the dialog, but you can regenerate it at any time in integration settings.
|
||||
{{< /admonition >}}
|
||||
|
||||
1. Generate a new ServiceNow Business Rule script and copy it to your clipboard.
|
||||
1. In ServiceNow, navigate to **System Definition** > **Business Rules** and click **New**.
|
||||
1. Fill in the following details:
|
||||
* Name: `grafana-oncall`
|
||||
* Table: `incident`
|
||||
* Active: ✔
|
||||
|
|
@ -74,9 +95,9 @@ Fill in the following details:
|
|||
* When to run > Insert: ✔
|
||||
* When to run > Update: ✔
|
||||
* Advanced > Script: Paste the generated script
|
||||
1. Click **Submit** to save the Business Rule.
|
||||
|
||||
Click **Submit** to save the Business Rule.
|
||||
10. In Grafana OnCall, click **Proceed** to complete the integration setup.
|
||||
In Grafana OnCall, click **Proceed** to complete the integration setup.
|
||||
|
||||
## Test the integration
|
||||
|
||||
|
|
@ -87,31 +108,31 @@ Fill in the following details:
|
|||
|
||||
## Connect other integrations
|
||||
|
||||
You can connect other integrations such as Alertmanager, Grafana Alerting, and others to an existing ServiceNow
|
||||
integration. When connected, Grafana OnCall will send alerts from the connected integrations to ServiceNow, and update
|
||||
alert groups on the connected integrations based on incident state changes in ServiceNow. Connected integrations will
|
||||
use the same ServiceNow credentials and outgoing webhooks as the ServiceNow integration they are connected to.
|
||||
You can connect other integrations such as Alertmanager, Grafana Alerting, and others to your ServiceNow integration for a consolidated workflow.
|
||||
When connected, Grafana OnCall sends alerts from the connected integrations to ServiceNow and update alert groups on the connected integrations based on incident
|
||||
state changes in ServiceNow.
|
||||
Connected integrations utilize the same ServiceNow credentials and outgoing webhooks as the ServiceNow integration they are connected to.
|
||||
|
||||
To connect other integrations:
|
||||
|
||||
1. Navigate to the **Outgoing** tab of an existing ServiceNow integration.
|
||||
2. Use the **Send data from other integrations** section to connect other integrations.
|
||||
3. Enable the **backsync** option if you want alert groups from connected integrations to be updated from ServiceNow.
|
||||
If disabled, Grafana OnCall will only send alerts to ServiceNow, but not receive updates back.
|
||||
If disabled, Grafana OnCall will only send alerts to ServiceNow, but not receive updates back.
|
||||
4. Test the connection by creating a demo alert for the connected integration.
|
||||
* Verify that an incident is created in ServiceNow.
|
||||
* Verify that incident state changes in ServiceNow are reflected in Grafana OnCall, and vice-versa.
|
||||
|
||||
## Advanced usage
|
||||
|
||||
You can customize the integration behaviour by editing the outgoing webhooks on the **Outgoing** tab of the integration.
|
||||
Customize the integration behavior according to your needs by editing the outgoing webhooks on the **Outgoing** tab of the integration.
|
||||
|
||||
### Custom incident fields
|
||||
|
||||
You can set custom fields on ServiceNow incidents. To do so, edit the **Alert group created** webhook on
|
||||
the **Outgoing** tab of the integration.
|
||||
|
||||
Example: to set the "urgency" field based on alert group labels, add the following to **Data template**:
|
||||
Example: Set the "urgency" field based on alert group labels, add the provided JSON to **Data template**:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -120,7 +141,7 @@ Example: to set the "urgency" field based on alert group labels, add the followi
|
|||
}
|
||||
```
|
||||
|
||||
Refer to [Outgoing webhook templates] and [Alert group labels] for more info.
|
||||
For more information, refer to [Outgoing webhook templates] and [Alert group labels].
|
||||
|
||||
{{% docs/reference %}}
|
||||
[Alert group labels]: "/docs/oncall/ -> /docs/oncall/<ONCALL_VERSION>/integrations#alert-group-labels"
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
|||
&& rm grpcio-1.57.0-cp311-cp311-linux_aarch64.whl; \
|
||||
fi
|
||||
|
||||
RUN pip install uv
|
||||
|
||||
# TODO: figure out how to get this to work.. see comment in .github/workflows/e2e-tests.yml
|
||||
# https://stackoverflow.com/a/71846527
|
||||
# RUN --mount=type=cache,target=/root/.cache/pip,from=pip_cache pip install -r requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
|
||||
# we intentionally have two COPY commands, this is to have the requirements.txt in a separate build step
|
||||
# which only invalidates when the requirements.txt actually changes. This avoids having to unneccasrily reinstall deps (which is time-consuming)
|
||||
|
|
@ -63,13 +65,13 @@ RUN apk add sqlite mysql-client postgresql-client
|
|||
# TODO: figure out how to get this to work.. see comment in .github/workflows/e2e-tests.yml
|
||||
# https://stackoverflow.com/a/71846527
|
||||
# RUN --mount=type=cache,target=/root/.cache/pip,from=pip_cache pip install -r requirements-dev.txt
|
||||
RUN pip install -r requirements-dev.txt
|
||||
RUN uv pip install --system -r requirements-dev.txt
|
||||
|
||||
FROM dev AS dev-enterprise
|
||||
# TODO: figure out how to get this to work.. see comment in .github/workflows/e2e-tests.yml
|
||||
# https://stackoverflow.com/a/71846527
|
||||
# RUN --mount=type=cache,target=/root/.cache/pip,from=pip_cache pip install -r requirements-enterprise-docker.txt
|
||||
RUN pip install -r requirements-enterprise-docker.txt
|
||||
RUN uv pip install --system -r requirements-enterprise-docker.txt
|
||||
|
||||
FROM base AS prod
|
||||
|
||||
|
|
|
|||
|
|
@ -472,24 +472,32 @@ class EscalationPolicySnapshot:
|
|||
def _escalation_step_trigger_custom_webhook(self, alert_group: "AlertGroup", _reason: str) -> None:
|
||||
tasks = []
|
||||
webhook = self.custom_webhook
|
||||
failure_reason = None
|
||||
if webhook is not None:
|
||||
custom_webhook_task = custom_webhook_result.signature(
|
||||
(webhook.pk, alert_group.pk),
|
||||
{
|
||||
"escalation_policy_pk": self.id,
|
||||
},
|
||||
immutable=True,
|
||||
)
|
||||
tasks.append(custom_webhook_task)
|
||||
if webhook.is_webhook_enabled:
|
||||
custom_webhook_task = custom_webhook_result.signature(
|
||||
(webhook.pk, alert_group.pk),
|
||||
{
|
||||
"escalation_policy_pk": self.id,
|
||||
},
|
||||
immutable=True,
|
||||
)
|
||||
tasks.append(custom_webhook_task)
|
||||
else:
|
||||
failure_reason = AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_IS_DISABLED
|
||||
else:
|
||||
failure_reason = AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_STEP_IS_NOT_CONFIGURED
|
||||
|
||||
if failure_reason:
|
||||
log_record = AlertGroupLogRecord(
|
||||
type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED,
|
||||
alert_group=alert_group,
|
||||
escalation_policy=self.escalation_policy,
|
||||
escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_STEP_IS_NOT_CONFIGURED,
|
||||
escalation_error_code=failure_reason,
|
||||
escalation_policy_step=self.step,
|
||||
)
|
||||
log_record.save()
|
||||
|
||||
self._execute_tasks(tasks)
|
||||
|
||||
def _escalation_step_repeat_escalation_n_times(
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ class AlertGroupLogRecord(models.Model):
|
|||
ERROR_ESCALATION_NOTIFY_IF_NUM_ALERTS_IN_WINDOW_STEP_IS_NOT_CONFIGURED,
|
||||
ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR,
|
||||
ERROR_ESCALATION_NOTIFY_TEAM_MEMBERS_STEP_IS_NOT_CONFIGURED,
|
||||
) = range(19)
|
||||
ERROR_ESCALATION_TRIGGER_WEBHOOK_IS_DISABLED,
|
||||
) = range(20)
|
||||
|
||||
type = models.IntegerField(choices=TYPE_CHOICES)
|
||||
|
||||
|
|
@ -590,6 +591,8 @@ class AlertGroupLogRecord(models.Model):
|
|||
usergroup_handle = self.escalation_policy.notify_to_group.handle
|
||||
usergroup_handle_text = f" @{usergroup_handle}" if usergroup_handle else ""
|
||||
result += f"failed to notify User Group{usergroup_handle_text} in Slack"
|
||||
elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_IS_DISABLED:
|
||||
result += 'skipped escalation step "Trigger Outgoing Webhook" because it is disabled'
|
||||
return result
|
||||
|
||||
def get_step_specific_info(self):
|
||||
|
|
|
|||
|
|
@ -486,6 +486,33 @@ def test_escalation_step_trigger_custom_webhook(
|
|||
mock_webhook_escalation_step.assert_called_once_with(alert_group, reason)
|
||||
|
||||
|
||||
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_step_trigger_disabled_custom_webhook(
|
||||
mocked_execute_tasks,
|
||||
escalation_step_test_setup,
|
||||
make_custom_webhook,
|
||||
make_escalation_policy,
|
||||
):
|
||||
organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup
|
||||
|
||||
custom_webhook = make_custom_webhook(organization=organization, is_webhook_enabled=False)
|
||||
|
||||
trigger_custom_webhook_step = make_escalation_policy(
|
||||
escalation_chain=channel_filter.escalation_chain,
|
||||
escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK,
|
||||
custom_webhook=custom_webhook,
|
||||
)
|
||||
escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(trigger_custom_webhook_step)
|
||||
escalation_policy_snapshot.execute(alert_group, reason)
|
||||
assert call([]) in mocked_execute_tasks.call_args_list
|
||||
|
||||
log_record = AlertGroupLogRecord.objects.get(
|
||||
alert_group_id=alert_group.id, escalation_policy=trigger_custom_webhook_step
|
||||
)
|
||||
assert log_record.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_WEBHOOK_IS_DISABLED
|
||||
|
||||
|
||||
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_step_repeat_escalation_n_times(
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_he
|
|||
|
||||
url = reverse("api-internal:alertgroup-list")
|
||||
response = client.get(
|
||||
url + "?started_at=1970-01-01T00:00:00/2099-01-01T23:59:59",
|
||||
url + "?started_at=1970-01-01T00:00:00_2099-01-01T23:59:59",
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
|
@ -126,7 +126,7 @@ def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api
|
|||
|
||||
url = reverse("api-internal:alertgroup-list")
|
||||
response = client.get(
|
||||
url + "?resolved_at=1970-01-01T00:00:00/1970-01-01T23:59:59",
|
||||
url + "?resolved_at=1970-01-01T00:00:00_1970-01-01T23:59:59",
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
|
@ -153,7 +153,7 @@ def test_get_filter_resolved_at(alert_group_internal_api_setup, make_user_auth_h
|
|||
|
||||
url = reverse("api-internal:alertgroup-list")
|
||||
response = client.get(
|
||||
url + "?resolved_at=1970-01-01T00:00:00/2099-01-01T23:59:59",
|
||||
url + "?resolved_at=1970-01-01T00:00:00_2099-01-01T23:59:59",
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ class AlertGroupView(
|
|||
now = timezone.now()
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
default_datetime_range = "{}/{}".format(
|
||||
default_datetime_range = "{}_{}".format(
|
||||
week_ago.strftime(DateRangeFilterMixin.DATE_FORMAT),
|
||||
now.strftime(DateRangeFilterMixin.DATE_FORMAT),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import typing
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
class AlertGroupStateDict(typing.TypedDict):
|
||||
firing: int
|
||||
acknowledged: int
|
||||
silenced: int
|
||||
resolved: int
|
||||
|
||||
|
||||
class AlertGroupsTotalMetricsDict(typing.TypedDict):
|
||||
integration_name: str
|
||||
team_name: str
|
||||
|
|
@ -11,10 +18,7 @@ class AlertGroupsTotalMetricsDict(typing.TypedDict):
|
|||
org_id: int
|
||||
slug: str
|
||||
id: int
|
||||
firing: int
|
||||
acknowledged: int
|
||||
silenced: int
|
||||
resolved: int
|
||||
services: typing.Dict[str, AlertGroupStateDict]
|
||||
|
||||
|
||||
class AlertGroupsResponseTimeMetricsDict(typing.TypedDict):
|
||||
|
|
@ -24,7 +28,7 @@ class AlertGroupsResponseTimeMetricsDict(typing.TypedDict):
|
|||
org_id: int
|
||||
slug: str
|
||||
id: int
|
||||
response_time: list
|
||||
services: typing.Dict[str, list]
|
||||
|
||||
|
||||
class UserWasNotifiedOfAlertGroupsMetricsDict(typing.TypedDict):
|
||||
|
|
@ -61,3 +65,6 @@ METRICS_RECALCULATION_CACHE_TIMEOUT_DISPERSE = (0, 3600) # 1 hour
|
|||
|
||||
METRICS_ORGANIZATIONS_IDS = "metrics_organizations_ids"
|
||||
METRICS_ORGANIZATIONS_IDS_CACHE_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
SERVICE_LABEL = "service_name"
|
||||
NO_SERVICE_VALUE = "No service"
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ from apps.metrics_exporter.constants import (
|
|||
METRICS_RECALCULATION_CACHE_TIMEOUT,
|
||||
METRICS_RECALCULATION_CACHE_TIMEOUT_DISPERSE,
|
||||
METRICS_RESPONSE_TIME_CALCULATION_PERIOD,
|
||||
NO_SERVICE_VALUE,
|
||||
USER_WAS_NOTIFIED_OF_ALERT_GROUPS,
|
||||
AlertGroupsResponseTimeMetricsDict,
|
||||
AlertGroupStateDict,
|
||||
AlertGroupsTotalMetricsDict,
|
||||
RecalculateMetricsTimer,
|
||||
UserWasNotifiedOfAlertGroupsMetricsDict,
|
||||
|
|
@ -126,6 +128,15 @@ def get_metric_calculation_started_key(metric_name) -> str:
|
|||
return f"calculation_started_for_{metric_name}"
|
||||
|
||||
|
||||
def get_default_states_dict() -> AlertGroupStateDict:
|
||||
return {
|
||||
AlertGroupState.FIRING.value: 0,
|
||||
AlertGroupState.ACKNOWLEDGED.value: 0,
|
||||
AlertGroupState.RESOLVED.value: 0,
|
||||
AlertGroupState.SILENCED.value: 0,
|
||||
}
|
||||
|
||||
|
||||
def metrics_update_integration_cache(integration: "AlertReceiveChannel") -> None:
|
||||
"""Update integration data in metrics cache"""
|
||||
metrics_cache_timeout = get_metrics_cache_timeout(integration.organization_id)
|
||||
|
|
@ -185,10 +196,7 @@ def metrics_add_integrations_to_cache(integrations: list["AlertReceiveChannel"],
|
|||
"org_id": grafana_org_id,
|
||||
"slug": instance_slug,
|
||||
"id": instance_id,
|
||||
AlertGroupState.FIRING.value: 0,
|
||||
AlertGroupState.ACKNOWLEDGED.value: 0,
|
||||
AlertGroupState.RESOLVED.value: 0,
|
||||
AlertGroupState.SILENCED.value: 0,
|
||||
"services": {NO_SERVICE_VALUE: get_default_states_dict()},
|
||||
},
|
||||
)
|
||||
cache.set(metric_alert_groups_total_key, metric_alert_groups_total, timeout=metrics_cache_timeout)
|
||||
|
|
@ -208,13 +216,13 @@ def metrics_add_integrations_to_cache(integrations: list["AlertReceiveChannel"],
|
|||
"org_id": grafana_org_id,
|
||||
"slug": instance_slug,
|
||||
"id": instance_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: []},
|
||||
},
|
||||
)
|
||||
cache.set(metric_alert_groups_response_time_key, metric_alert_groups_response_time, timeout=metrics_cache_timeout)
|
||||
|
||||
|
||||
def metrics_bulk_update_team_label_cache(teams_updated_data, organization_id):
|
||||
def metrics_bulk_update_team_label_cache(teams_updated_data: dict, organization_id: int):
|
||||
"""Update team related data in metrics cache for each team in `teams_updated_data`"""
|
||||
if not teams_updated_data:
|
||||
return
|
||||
|
|
@ -243,8 +251,29 @@ def metrics_bulk_update_team_label_cache(teams_updated_data, organization_id):
|
|||
cache.set(metric_alert_groups_response_time_key, metric_alert_groups_response_time, timeout=metrics_cache_timeout)
|
||||
|
||||
|
||||
def metrics_update_alert_groups_state_cache(states_diff, organization_id):
|
||||
"""Update alert groups state metric cache for each integration in states_diff dict."""
|
||||
def metrics_update_alert_groups_state_cache(states_diff: dict, organization_id: int):
|
||||
"""
|
||||
Update alert groups state metric cache for each integration in states_diff dict.
|
||||
states_diff example:
|
||||
{
|
||||
<integration_id>: {
|
||||
<service name>: {
|
||||
"previous_states": {
|
||||
firing: 1,
|
||||
acknowledged: 0,
|
||||
resolved: 0,
|
||||
silenced: 0,
|
||||
},
|
||||
"new_states": {
|
||||
firing: 0,
|
||||
acknowledged: 1,
|
||||
resolved: 0,
|
||||
silenced: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not states_diff:
|
||||
return
|
||||
|
||||
|
|
@ -253,23 +282,40 @@ def metrics_update_alert_groups_state_cache(states_diff, organization_id):
|
|||
metric_alert_groups_total = cache.get(metric_alert_groups_total_key, {})
|
||||
if not metric_alert_groups_total:
|
||||
return
|
||||
for integration_id, integration_states_diff in states_diff.items():
|
||||
for integration_id, service_data in states_diff.items():
|
||||
integration_alert_groups = metric_alert_groups_total.get(int(integration_id))
|
||||
if not integration_alert_groups:
|
||||
continue
|
||||
for previous_state, counter in integration_states_diff["previous_states"].items():
|
||||
if integration_alert_groups[previous_state] - counter > 0:
|
||||
integration_alert_groups[previous_state] -= counter
|
||||
for service_name, service_state_diff in service_data.items():
|
||||
if "services" in integration_alert_groups:
|
||||
states_to_update = integration_alert_groups["services"].setdefault(
|
||||
service_name, get_default_states_dict()
|
||||
)
|
||||
else:
|
||||
integration_alert_groups[previous_state] = 0
|
||||
for new_state, counter in integration_states_diff["new_states"].items():
|
||||
integration_alert_groups[new_state] += counter
|
||||
# support version of metrics cache without service name. This clause can be removed when all metrics
|
||||
# cache is updated on prod (~2 days after release)
|
||||
states_to_update = integration_alert_groups
|
||||
for previous_state, counter in service_state_diff["previous_states"].items():
|
||||
if states_to_update[previous_state] - counter > 0:
|
||||
states_to_update[previous_state] -= counter
|
||||
else:
|
||||
states_to_update[previous_state] = 0
|
||||
for new_state, counter in service_state_diff["new_states"].items():
|
||||
states_to_update[new_state] += counter
|
||||
|
||||
cache.set(metric_alert_groups_total_key, metric_alert_groups_total, timeout=metrics_cache_timeout)
|
||||
|
||||
|
||||
def metrics_update_alert_groups_response_time_cache(integrations_response_time, organization_id):
|
||||
"""Update alert groups response time metric cache for each integration in `integrations_response_time` dict."""
|
||||
def metrics_update_alert_groups_response_time_cache(integrations_response_time: dict, organization_id: int):
|
||||
"""
|
||||
Update alert groups response time metric cache for each integration in `integrations_response_time` dict.
|
||||
integrations_response_time dict example:
|
||||
{
|
||||
<integration_id>: {
|
||||
<service name>: [10],
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not integrations_response_time:
|
||||
return
|
||||
|
||||
|
|
@ -278,11 +324,18 @@ def metrics_update_alert_groups_response_time_cache(integrations_response_time,
|
|||
metric_alert_groups_response_time = cache.get(metric_alert_groups_response_time_key, {})
|
||||
if not metric_alert_groups_response_time:
|
||||
return
|
||||
for integration_id, integration_response_time in integrations_response_time.items():
|
||||
for integration_id, service_data in integrations_response_time.items():
|
||||
integration_response_time_metrics = metric_alert_groups_response_time.get(int(integration_id))
|
||||
if not integration_response_time_metrics:
|
||||
continue
|
||||
integration_response_time_metrics["response_time"].extend(integration_response_time)
|
||||
for service_name, response_time_values in service_data.items():
|
||||
if "services" in integration_response_time_metrics:
|
||||
integration_response_time_metrics["services"].setdefault(service_name, [])
|
||||
integration_response_time_metrics["services"][service_name].extend(response_time_values)
|
||||
else:
|
||||
# support version of metrics cache without service name. This clause can be removed when all metrics
|
||||
# cache is updated on prod (~2 days after release)
|
||||
integration_response_time_metrics["response_time"].extend(response_time_values)
|
||||
cache.set(metric_alert_groups_response_time_key, metric_alert_groups_response_time, timeout=metrics_cache_timeout)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,56 +40,60 @@ class MetricsCacheManager:
|
|||
return default_dict
|
||||
|
||||
@staticmethod
|
||||
def update_integration_states_diff(metrics_dict, integration_id, previous_state=None, new_state=None):
|
||||
metrics_dict.setdefault(integration_id, MetricsCacheManager.get_default_states_diff_dict())
|
||||
def update_integration_states_diff(metrics_dict, integration_id, service_name, previous_state=None, new_state=None):
|
||||
state_per_service = metrics_dict.setdefault(
|
||||
integration_id, {service_name: MetricsCacheManager.get_default_states_diff_dict()}
|
||||
)
|
||||
if previous_state:
|
||||
state_value = previous_state
|
||||
metrics_dict[integration_id]["previous_states"][state_value] += 1
|
||||
state_per_service[service_name]["previous_states"][state_value] += 1
|
||||
if new_state:
|
||||
state_value = new_state
|
||||
metrics_dict[integration_id]["new_states"][state_value] += 1
|
||||
state_per_service[service_name]["new_states"][state_value] += 1
|
||||
return metrics_dict
|
||||
|
||||
@staticmethod
|
||||
def update_integration_response_time_diff(metrics_dict, integration_id, response_time_seconds):
|
||||
metrics_dict.setdefault(integration_id, [])
|
||||
metrics_dict[integration_id].append(response_time_seconds)
|
||||
return metrics_dict
|
||||
|
||||
@staticmethod
|
||||
def metrics_update_state_cache_for_alert_group(integration_id, organization_id, old_state=None, new_state=None):
|
||||
def metrics_update_state_cache_for_alert_group(
|
||||
integration_id, organization_id, service_name, old_state=None, new_state=None
|
||||
):
|
||||
"""
|
||||
Update state metric cache for one alert group.
|
||||
Run the task to update async if organization_id is None due to an additional request to db
|
||||
"""
|
||||
metrics_state_diff = MetricsCacheManager.update_integration_states_diff(
|
||||
{}, integration_id, previous_state=old_state, new_state=new_state
|
||||
{}, integration_id, service_name, previous_state=old_state, new_state=new_state
|
||||
)
|
||||
metrics_update_alert_groups_state_cache(metrics_state_diff, organization_id)
|
||||
|
||||
@staticmethod
|
||||
def metrics_update_response_time_cache_for_alert_group(integration_id, organization_id, response_time_seconds):
|
||||
def metrics_update_response_time_cache_for_alert_group(
|
||||
integration_id, organization_id, response_time_seconds, service_name
|
||||
):
|
||||
"""
|
||||
Update response time metric cache for one alert group.
|
||||
Run the task to update async if organization_id is None due to an additional request to db
|
||||
"""
|
||||
metrics_response_time = MetricsCacheManager.update_integration_response_time_diff(
|
||||
{}, integration_id, response_time_seconds
|
||||
)
|
||||
metrics_response_time: typing.Dict[int, typing.Dict[str, typing.List[int]]] = {
|
||||
integration_id: {service_name: [response_time_seconds]}
|
||||
}
|
||||
metrics_update_alert_groups_response_time_cache(metrics_response_time, organization_id)
|
||||
|
||||
@staticmethod
|
||||
def metrics_update_cache_for_alert_group(
|
||||
integration_id, organization_id, old_state=None, new_state=None, response_time=None, started_at=None
|
||||
integration_id,
|
||||
organization_id,
|
||||
old_state=None,
|
||||
new_state=None,
|
||||
response_time=None,
|
||||
started_at=None,
|
||||
service_name=None,
|
||||
):
|
||||
"""Call methods to update state and response time metrics cache for one alert group."""
|
||||
|
||||
if response_time and old_state == AlertGroupState.FIRING and started_at > get_response_time_period():
|
||||
response_time_seconds = int(response_time.total_seconds())
|
||||
MetricsCacheManager.metrics_update_response_time_cache_for_alert_group(
|
||||
integration_id, organization_id, response_time_seconds
|
||||
integration_id, organization_id, response_time_seconds, service_name
|
||||
)
|
||||
if old_state or new_state:
|
||||
MetricsCacheManager.metrics_update_state_cache_for_alert_group(
|
||||
integration_id, organization_id, old_state, new_state
|
||||
integration_id, organization_id, service_name, old_state, new_state
|
||||
)
|
||||
|
|
|
|||
|
|
@ -46,10 +46,14 @@ class ApplicationMetricsCollector:
|
|||
"slug",
|
||||
"id",
|
||||
]
|
||||
self._integration_labels = [
|
||||
"integration",
|
||||
"team",
|
||||
] + self._stack_labels
|
||||
self._integration_labels = (
|
||||
[
|
||||
"integration",
|
||||
"team",
|
||||
]
|
||||
+ self._stack_labels
|
||||
# + [SERVICE_LABEL] # todo:metrics: uncomment when all metric cache is updated (~2 after release)
|
||||
)
|
||||
self._integration_labels_with_state = self._integration_labels + ["state"]
|
||||
self._user_labels = ["username"] + self._stack_labels
|
||||
|
||||
|
|
@ -96,8 +100,24 @@ class ApplicationMetricsCollector:
|
|||
integration_data["id"], # grafana instance id
|
||||
]
|
||||
labels_values = list(map(str, labels_values))
|
||||
for state in AlertGroupState:
|
||||
alert_groups_total.add_metric(labels_values + [state.value], integration_data[state.value])
|
||||
# clause below is needed for compatibility with old metric cache during rollout metrics with services
|
||||
if "services" in integration_data:
|
||||
count_per_state = {state.value: 0 for state in AlertGroupState}
|
||||
for service_name in integration_data["services"]:
|
||||
for state in AlertGroupState:
|
||||
count_per_state[state.value] += integration_data["services"][service_name][state.value]
|
||||
# todo:metrics: with enabling service_name label move "add_metric" under
|
||||
# "for service_name..." iteration
|
||||
for state_name, counter in count_per_state.items():
|
||||
alert_groups_total.add_metric(
|
||||
labels_values + [state_name],
|
||||
# todo:metrics: replace [state.value] when all metric cache is updated
|
||||
# + [service_name, state.value],
|
||||
counter,
|
||||
)
|
||||
else:
|
||||
for state in AlertGroupState:
|
||||
alert_groups_total.add_metric(labels_values + [state.value], integration_data[state.value])
|
||||
org_id_from_key = RE_ALERT_GROUPS_TOTAL.match(org_key).groups()[0]
|
||||
processed_org_ids.add(int(org_id_from_key))
|
||||
missing_org_ids = org_ids - processed_org_ids
|
||||
|
|
@ -126,12 +146,27 @@ class ApplicationMetricsCollector:
|
|||
]
|
||||
labels_values = list(map(str, labels_values))
|
||||
|
||||
response_time_values = integration_data["response_time"]
|
||||
if not response_time_values:
|
||||
continue
|
||||
# clause below is needed for compatibility with old metric cache during rollout metrics with services
|
||||
if "services" in integration_data:
|
||||
response_time_values = []
|
||||
# todo:metrics: for service_name, response_time
|
||||
for _, response_time in integration_data["services"].items():
|
||||
if not response_time:
|
||||
continue
|
||||
response_time_values.extend(response_time)
|
||||
else:
|
||||
response_time_values = integration_data["response_time"]
|
||||
if not response_time_values:
|
||||
continue
|
||||
# todo:metrics: with enabling service_name label move "add_metric" under
|
||||
# "for service_name, response_time..." iteration
|
||||
buckets, sum_value = self.get_buckets_with_sum(response_time_values)
|
||||
buckets = sorted(list(buckets.items()), key=lambda x: float(x[0]))
|
||||
alert_groups_response_time_seconds.add_metric(labels_values, buckets=buckets, sum_value=sum_value)
|
||||
alert_groups_response_time_seconds.add_metric(
|
||||
labels_values, # + [service_name] todo:metrics: uncomment when all metric cache is updated
|
||||
buckets=buckets,
|
||||
sum_value=sum_value,
|
||||
)
|
||||
org_id_from_key = RE_ALERT_GROUPS_RESPONSE_TIME.match(org_key).groups()[0]
|
||||
processed_org_ids.add(int(org_id_from_key))
|
||||
missing_org_ids = org_ids - processed_org_ids
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ from apps.alerts.constants import AlertGroupState
|
|||
from apps.metrics_exporter.constants import (
|
||||
METRICS_ORGANIZATIONS_IDS,
|
||||
METRICS_ORGANIZATIONS_IDS_CACHE_TIMEOUT,
|
||||
NO_SERVICE_VALUE,
|
||||
SERVICE_LABEL,
|
||||
AlertGroupsResponseTimeMetricsDict,
|
||||
AlertGroupsTotalMetricsDict,
|
||||
RecalculateOrgMetricsDict,
|
||||
UserWasNotifiedOfAlertGroupsMetricsDict,
|
||||
)
|
||||
from apps.metrics_exporter.helpers import (
|
||||
get_default_states_dict,
|
||||
get_metric_alert_groups_response_time_key,
|
||||
get_metric_alert_groups_total_key,
|
||||
get_metric_calculation_started_key,
|
||||
|
|
@ -111,37 +114,80 @@ def calculate_and_cache_metrics(organization_id, force=False):
|
|||
}
|
||||
|
||||
for integration in integrations:
|
||||
# calculate states
|
||||
for state, alert_group_filter in states.items():
|
||||
metric_alert_group_total.setdefault(
|
||||
integration.id,
|
||||
{
|
||||
"integration_name": integration.emojized_verbal_name,
|
||||
"team_name": integration.team_name,
|
||||
"team_id": integration.team_id_or_no_team,
|
||||
"org_id": instance_org_id,
|
||||
"slug": instance_slug,
|
||||
"id": instance_id,
|
||||
},
|
||||
)[state] = integration.alert_groups.filter(alert_group_filter).count()
|
||||
|
||||
# get response time
|
||||
all_response_time = integration.alert_groups.filter(
|
||||
started_at__gte=response_time_period,
|
||||
response_time__isnull=False,
|
||||
).values_list("response_time", flat=True)
|
||||
|
||||
all_response_time_seconds = [int(response_time.total_seconds()) for response_time in all_response_time]
|
||||
|
||||
metric_alert_group_response_time[integration.id] = {
|
||||
metric_alert_group_total_data = {
|
||||
"integration_name": integration.emojized_verbal_name,
|
||||
"team_name": integration.team_name,
|
||||
"team_id": integration.team_id_or_no_team,
|
||||
"org_id": instance_org_id,
|
||||
"slug": instance_slug,
|
||||
"id": instance_id,
|
||||
"response_time": all_response_time_seconds,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: get_default_states_dict(),
|
||||
},
|
||||
}
|
||||
# calculate states
|
||||
for state, alert_group_filter in states.items():
|
||||
# count alert groups with `service_name` label group by label value
|
||||
alert_group_count_by_service = (
|
||||
integration.alert_groups.filter(
|
||||
alert_group_filter,
|
||||
labels__organization=organization,
|
||||
labels__key_name=SERVICE_LABEL,
|
||||
)
|
||||
.values("labels__value_name")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
for value in alert_group_count_by_service:
|
||||
metric_alert_group_total_data["services"].setdefault(
|
||||
value["labels__value_name"],
|
||||
get_default_states_dict(),
|
||||
)[state] += value["count"]
|
||||
# count alert groups without `service_name` label
|
||||
alert_groups_count_without_service = integration.alert_groups.filter(
|
||||
alert_group_filter,
|
||||
~Q(labels__key_name=SERVICE_LABEL),
|
||||
).count()
|
||||
metric_alert_group_total_data["services"][NO_SERVICE_VALUE][state] += alert_groups_count_without_service
|
||||
metric_alert_group_total[integration.id] = metric_alert_group_total_data
|
||||
|
||||
# calculate response time metric
|
||||
metric_response_time_data = {
|
||||
"integration_name": integration.emojized_verbal_name,
|
||||
"team_name": integration.team_name,
|
||||
"team_id": integration.team_id_or_no_team,
|
||||
"org_id": instance_org_id,
|
||||
"slug": instance_slug,
|
||||
"id": instance_id,
|
||||
"services": {NO_SERVICE_VALUE: []},
|
||||
}
|
||||
|
||||
# filter response time by services
|
||||
response_time_by_service = integration.alert_groups.filter(
|
||||
started_at__gte=response_time_period,
|
||||
response_time__isnull=False,
|
||||
labels__organization=organization,
|
||||
labels__key_name=SERVICE_LABEL,
|
||||
).values_list("id", "labels__value_name", "response_time")
|
||||
for _, service_name, response_time in response_time_by_service:
|
||||
metric_response_time_data["services"].setdefault(service_name, [])
|
||||
metric_response_time_data["services"][service_name].append(response_time.total_seconds())
|
||||
|
||||
no_service_response_time = (
|
||||
integration.alert_groups.filter(
|
||||
started_at__gte=response_time_period,
|
||||
response_time__isnull=False,
|
||||
)
|
||||
.exclude(id__in=[i[0] for i in response_time_by_service])
|
||||
.values_list("response_time", flat=True)
|
||||
)
|
||||
|
||||
no_service_response_time_seconds = [
|
||||
int(response_time.total_seconds()) for response_time in no_service_response_time
|
||||
]
|
||||
metric_response_time_data["services"][NO_SERVICE_VALUE] = no_service_response_time_seconds
|
||||
|
||||
metric_alert_group_response_time[integration.id] = metric_response_time_data
|
||||
|
||||
metric_alert_groups_total_key = get_metric_alert_groups_total_key(organization_id)
|
||||
metric_alert_groups_response_time_key = get_metric_alert_groups_response_time_key(organization_id)
|
||||
|
|
@ -223,6 +269,8 @@ def update_metrics_for_alert_group(alert_group_id, organization_id, previous_sta
|
|||
if previous_state != AlertGroupState.FIRING or alert_group.restarted_at:
|
||||
# only consider response time from the first action
|
||||
updated_response_time = None
|
||||
service_label = alert_group.labels.filter(key_name=SERVICE_LABEL).first()
|
||||
service_name = service_label.value_name if service_label else NO_SERVICE_VALUE
|
||||
MetricsCacheManager.metrics_update_cache_for_alert_group(
|
||||
integration_id=alert_group.channel_id,
|
||||
organization_id=organization_id,
|
||||
|
|
@ -230,6 +278,7 @@ def update_metrics_for_alert_group(alert_group_id, organization_id, previous_sta
|
|||
new_state=new_state,
|
||||
response_time=updated_response_time,
|
||||
started_at=alert_group.started_at,
|
||||
service_name=service_name,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.core.cache import cache
|
|||
from apps.metrics_exporter.constants import (
|
||||
ALERT_GROUPS_RESPONSE_TIME,
|
||||
ALERT_GROUPS_TOTAL,
|
||||
NO_SERVICE_VALUE,
|
||||
USER_WAS_NOTIFIED_OF_ALERT_GROUPS,
|
||||
)
|
||||
from apps.metrics_exporter.helpers import (
|
||||
|
|
@ -21,6 +22,67 @@ METRICS_TEST_USER_USERNAME = "Alex"
|
|||
|
||||
@pytest.fixture()
|
||||
def mock_cache_get_metrics_for_collector(monkeypatch):
|
||||
def _mock_cache_get(key, *args, **kwargs):
|
||||
if ALERT_GROUPS_TOTAL in key:
|
||||
key = ALERT_GROUPS_TOTAL
|
||||
elif ALERT_GROUPS_RESPONSE_TIME in key:
|
||||
key = ALERT_GROUPS_RESPONSE_TIME
|
||||
elif USER_WAS_NOTIFIED_OF_ALERT_GROUPS in key:
|
||||
key = USER_WAS_NOTIFIED_OF_ALERT_GROUPS
|
||||
test_metrics = {
|
||||
ALERT_GROUPS_TOTAL: {
|
||||
1: {
|
||||
"integration_name": "Test metrics integration",
|
||||
"team_name": "Test team",
|
||||
"team_id": 1,
|
||||
"org_id": 1,
|
||||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 2,
|
||||
"silenced": 4,
|
||||
"acknowledged": 3,
|
||||
"resolved": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ALERT_GROUPS_RESPONSE_TIME: {
|
||||
1: {
|
||||
"integration_name": "Test metrics integration",
|
||||
"team_name": "Test team",
|
||||
"team_id": 1,
|
||||
"org_id": 1,
|
||||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: [2, 10, 200, 650],
|
||||
},
|
||||
}
|
||||
},
|
||||
USER_WAS_NOTIFIED_OF_ALERT_GROUPS: {
|
||||
1: {
|
||||
"org_id": 1,
|
||||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"user_username": "Alex",
|
||||
"counter": 4,
|
||||
}
|
||||
},
|
||||
}
|
||||
return test_metrics.get(key)
|
||||
|
||||
def _mock_cache_get_many(keys, *args, **kwargs):
|
||||
return {key: _mock_cache_get(key) for key in keys if _mock_cache_get(key)}
|
||||
|
||||
monkeypatch.setattr(cache, "get", _mock_cache_get)
|
||||
monkeypatch.setattr(cache, "get_many", _mock_cache_get_many)
|
||||
|
||||
|
||||
# todo:metrics: remove later when all cache is updated
|
||||
@pytest.fixture() # used for test backwards compatibility with old version of metrics
|
||||
def mock_cache_get_metrics_for_collector_mixed_versions(monkeypatch):
|
||||
def _mock_cache_get(key, *args, **kwargs):
|
||||
if ALERT_GROUPS_TOTAL in key:
|
||||
key = ALERT_GROUPS_TOTAL
|
||||
|
|
@ -41,7 +103,29 @@ def mock_cache_get_metrics_for_collector(monkeypatch):
|
|||
"acknowledged": 3,
|
||||
"silenced": 4,
|
||||
"resolved": 5,
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"integration_name": "Test metrics integration 2",
|
||||
"team_name": "Test team",
|
||||
"team_id": 1,
|
||||
"org_id": 1,
|
||||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 2,
|
||||
"silenced": 4,
|
||||
"acknowledged": 3,
|
||||
"resolved": 5,
|
||||
},
|
||||
"test_service": {
|
||||
"firing": 10,
|
||||
"silenced": 10,
|
||||
"acknowledged": 10,
|
||||
"resolved": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ALERT_GROUPS_RESPONSE_TIME: {
|
||||
1: {
|
||||
|
|
@ -52,7 +136,16 @@ def mock_cache_get_metrics_for_collector(monkeypatch):
|
|||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"response_time": [2, 10, 200, 650],
|
||||
}
|
||||
},
|
||||
2: {
|
||||
"integration_name": "Test metrics integration 2",
|
||||
"team_name": "Test team",
|
||||
"team_id": 1,
|
||||
"org_id": 1,
|
||||
"slug": "Test stack",
|
||||
"id": 1,
|
||||
"services": {NO_SERVICE_VALUE: [2, 10, 200, 650], "test_service": [4, 8, 12]},
|
||||
},
|
||||
},
|
||||
USER_WAS_NOTIFIED_OF_ALERT_GROUPS: {
|
||||
1: {
|
||||
|
|
@ -87,6 +180,56 @@ def mock_get_metrics_cache(monkeypatch):
|
|||
|
||||
@pytest.fixture
|
||||
def make_metrics_cache_params(monkeypatch):
|
||||
def _make_cache_params(integration_id, organization_id, team_name=None, team_id=None):
|
||||
team_name = team_name or "No team"
|
||||
team_id = team_id or "no_team"
|
||||
metric_alert_groups_total_key = get_metric_alert_groups_total_key(organization_id)
|
||||
metric_alert_groups_response_time_key = get_metric_alert_groups_response_time_key(organization_id)
|
||||
|
||||
def cache_get(key, *args, **kwargs):
|
||||
metrics_data = {
|
||||
metric_alert_groups_response_time_key: {
|
||||
integration_id: {
|
||||
"integration_name": METRICS_TEST_INTEGRATION_NAME,
|
||||
"team_name": team_name,
|
||||
"team_id": team_id,
|
||||
"org_id": METRICS_TEST_ORG_ID,
|
||||
"slug": METRICS_TEST_INSTANCE_SLUG,
|
||||
"id": METRICS_TEST_INSTANCE_ID,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
metric_alert_groups_total_key: {
|
||||
integration_id: {
|
||||
"integration_name": METRICS_TEST_INTEGRATION_NAME,
|
||||
"team_name": team_name,
|
||||
"team_id": team_id,
|
||||
"org_id": METRICS_TEST_ORG_ID,
|
||||
"slug": METRICS_TEST_INSTANCE_SLUG,
|
||||
"id": METRICS_TEST_INSTANCE_ID,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return metrics_data.get(key, {})
|
||||
|
||||
return cache_get
|
||||
|
||||
return _make_cache_params
|
||||
|
||||
|
||||
# todo:metrics: remove later when all cache is updated
|
||||
@pytest.fixture
|
||||
def make_metrics_cache_params_old_version(monkeypatch):
|
||||
def _make_cache_params(integration_id, organization_id, team_name=None, team_id=None):
|
||||
team_name = team_name or "No team"
|
||||
team_id = team_id or "no_team"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.metrics_exporter.constants import NO_SERVICE_VALUE, SERVICE_LABEL
|
||||
from apps.metrics_exporter.helpers import (
|
||||
get_metric_alert_groups_response_time_key,
|
||||
get_metric_alert_groups_total_key,
|
||||
|
|
@ -21,6 +22,7 @@ def test_calculate_and_cache_metrics_task(
|
|||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_label_association,
|
||||
):
|
||||
METRICS_RESPONSE_TIME_LEN = 3 # 1 for each alert group with changed state (acked, resolved, silenced)
|
||||
organization = make_organization()
|
||||
|
|
@ -45,6 +47,13 @@ def test_calculate_and_cache_metrics_task(
|
|||
make_alert(alert_group=alert_group_to_sil, raw_request_data={})
|
||||
alert_group_to_sil.silence()
|
||||
|
||||
alert_group_to_ack_with_service = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group_to_ack, raw_request_data={})
|
||||
make_alert_group_label_association(
|
||||
organization, alert_group_to_ack_with_service, key_name=SERVICE_LABEL, value_name="test"
|
||||
)
|
||||
alert_group_to_ack_with_service.acknowledge()
|
||||
|
||||
metric_alert_groups_total_key = get_metric_alert_groups_total_key(organization.id)
|
||||
metric_alert_groups_response_time_key = get_metric_alert_groups_response_time_key(organization.id)
|
||||
|
||||
|
|
@ -56,10 +65,20 @@ def test_calculate_and_cache_metrics_task(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 2,
|
||||
"silenced": 1,
|
||||
"acknowledged": 1,
|
||||
"resolved": 1,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 2,
|
||||
"silenced": 1,
|
||||
"acknowledged": 1,
|
||||
"resolved": 1,
|
||||
},
|
||||
"test": {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 1,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
alert_receive_channel_2.id: {
|
||||
"integration_name": alert_receive_channel_2.verbal_name,
|
||||
|
|
@ -68,10 +87,20 @@ def test_calculate_and_cache_metrics_task(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 2,
|
||||
"silenced": 1,
|
||||
"acknowledged": 1,
|
||||
"resolved": 1,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 2,
|
||||
"silenced": 1,
|
||||
"acknowledged": 1,
|
||||
"resolved": 1,
|
||||
},
|
||||
"test": {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 1,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
expected_result_metric_alert_groups_response_time = {
|
||||
|
|
@ -82,7 +111,7 @@ def test_calculate_and_cache_metrics_task(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: [], "test": []},
|
||||
},
|
||||
alert_receive_channel_2.id: {
|
||||
"integration_name": alert_receive_channel_2.verbal_name,
|
||||
|
|
@ -91,7 +120,7 @@ def test_calculate_and_cache_metrics_task(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: [], "test": []},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -108,9 +137,14 @@ def test_calculate_and_cache_metrics_task(
|
|||
metric_alert_groups_response_time_values = args[1].args
|
||||
assert metric_alert_groups_response_time_values[0] == metric_alert_groups_response_time_key
|
||||
for integration_id, values in metric_alert_groups_response_time_values[1].items():
|
||||
assert len(values["response_time"]) == METRICS_RESPONSE_TIME_LEN
|
||||
assert len(values["services"][NO_SERVICE_VALUE]) == METRICS_RESPONSE_TIME_LEN
|
||||
# set response time to expected result because it is calculated on fly
|
||||
expected_result_metric_alert_groups_response_time[integration_id]["response_time"] = values["response_time"]
|
||||
expected_result_metric_alert_groups_response_time[integration_id]["services"][NO_SERVICE_VALUE] = values[
|
||||
"services"
|
||||
][NO_SERVICE_VALUE]
|
||||
expected_result_metric_alert_groups_response_time[integration_id]["services"]["test"] = values["services"][
|
||||
"test"
|
||||
]
|
||||
assert metric_alert_groups_response_time_values[1] == expected_result_metric_alert_groups_response_time
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,3 +44,37 @@ def test_application_metrics_collector(
|
|||
# Since there is no recalculation timer for test org in cache, start_calculate_and_cache_metrics must be called
|
||||
assert mocked_start_calculate_and_cache_metrics.called
|
||||
test_metrics_registry.unregister(collector)
|
||||
|
||||
|
||||
# todo:metrics: remove later when all cache is updated
|
||||
@patch("apps.metrics_exporter.metrics_collectors.get_organization_ids", return_value=[1])
|
||||
@patch("apps.metrics_exporter.metrics_collectors.start_calculate_and_cache_metrics.apply_async")
|
||||
@pytest.mark.django_db
|
||||
def test_application_metrics_collector_mixed_cache(
|
||||
mocked_org_ids, mocked_start_calculate_and_cache_metrics, mock_cache_get_metrics_for_collector_mixed_versions
|
||||
):
|
||||
"""Test that ApplicationMetricsCollector generates expected metrics from previous and new versions of cache"""
|
||||
|
||||
collector = ApplicationMetricsCollector()
|
||||
test_metrics_registry = CollectorRegistry()
|
||||
test_metrics_registry.register(collector)
|
||||
for metric in test_metrics_registry.collect():
|
||||
if metric.name == ALERT_GROUPS_TOTAL:
|
||||
# integration with labels for each alert group state
|
||||
assert len(metric.samples) == len(AlertGroupState) * 2
|
||||
# check that values from different services were combined to one sample
|
||||
assert {2, 3, 4, 5, 12, 13, 14, 15} == set(sample.value for sample in metric.samples)
|
||||
elif metric.name == ALERT_GROUPS_RESPONSE_TIME:
|
||||
# integration with labels for each value in collector's bucket + _count and _sum histogram values
|
||||
assert len(metric.samples) == (len(collector._buckets) + 2) * 2
|
||||
# check that values from different services were combined to one sample
|
||||
assert 7.0 in set(sample.value for sample in metric.samples)
|
||||
elif metric.name == USER_WAS_NOTIFIED_OF_ALERT_GROUPS:
|
||||
# metric with labels for each notified user
|
||||
assert len(metric.samples) == 1
|
||||
result = generate_latest(test_metrics_registry).decode("utf-8")
|
||||
assert result is not None
|
||||
assert mocked_org_ids.called
|
||||
# Since there is no recalculation timer for test org in cache, start_calculate_and_cache_metrics must be called
|
||||
assert mocked_start_calculate_and_cache_metrics.called
|
||||
test_metrics_registry.unregister(collector)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.test import override_settings
|
|||
from apps.alerts.signals import alert_group_created_signal
|
||||
from apps.alerts.tasks import notify_user_task
|
||||
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
|
||||
from apps.metrics_exporter.constants import NO_SERVICE_VALUE, SERVICE_LABEL
|
||||
from apps.metrics_exporter.helpers import (
|
||||
get_metric_alert_groups_response_time_key,
|
||||
get_metric_alert_groups_total_key,
|
||||
|
|
@ -23,6 +24,8 @@ from apps.metrics_exporter.tests.conftest import (
|
|||
METRICS_TEST_USER_USERNAME,
|
||||
)
|
||||
|
||||
TEST_SERVICE_VALUE = "Test_service"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_apply_async(monkeypatch):
|
||||
|
|
@ -44,6 +47,7 @@ def test_update_metric_alert_groups_total_cache_on_action(
|
|||
make_alert_group,
|
||||
make_alert,
|
||||
make_metrics_cache_params,
|
||||
make_alert_group_label_association,
|
||||
monkeypatch,
|
||||
):
|
||||
organization = make_organization(
|
||||
|
|
@ -64,13 +68,24 @@ def test_update_metric_alert_groups_total_cache_on_action(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
default_state = {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
}
|
||||
|
||||
expected_result_firing = {
|
||||
"firing": 1,
|
||||
"silenced": 0,
|
||||
|
|
@ -102,17 +117,21 @@ def test_update_metric_alert_groups_total_cache_on_action(
|
|||
metrics_cache = make_metrics_cache_params(alert_receive_channel.id, organization.id)
|
||||
monkeypatch.setattr(cache, "get", metrics_cache)
|
||||
|
||||
def get_called_arg_index_and_compare_results(update_expected_result):
|
||||
def get_called_arg_index_and_compare_results(update_expected_result, service_name=NO_SERVICE_VALUE):
|
||||
"""find index for the metric argument, that was set in cache"""
|
||||
for idx, called_arg in enumerate(mock_cache_set_called_args):
|
||||
if idx >= arg_idx and called_arg.args[0] == metric_alert_groups_total_key:
|
||||
expected_result_metric_alert_groups_total[alert_receive_channel.id].update(update_expected_result)
|
||||
expected_result_metric_alert_groups_total[alert_receive_channel.id]["services"].setdefault(
|
||||
service_name, {}
|
||||
).update(update_expected_result)
|
||||
assert called_arg.args[1] == expected_result_metric_alert_groups_total
|
||||
return idx + 1
|
||||
raise AssertionError
|
||||
|
||||
with patch("apps.metrics_exporter.tasks.cache.set") as mock_cache_set:
|
||||
arg_idx = 0
|
||||
|
||||
# create alert group without service label
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
# this signal is normally called in get_or_create_grouping on create alert
|
||||
|
|
@ -138,7 +157,25 @@ def test_update_metric_alert_groups_total_cache_on_action(
|
|||
arg_idx = get_called_arg_index_and_compare_results(expected_result_silenced)
|
||||
|
||||
alert_group.un_silence_by_user_or_backsync(user)
|
||||
get_called_arg_index_and_compare_results(expected_result_firing)
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_firing)
|
||||
|
||||
# create alert group with service label and check metric cache is updated properly
|
||||
expected_result_metric_alert_groups_total[alert_receive_channel.id]["services"][NO_SERVICE_VALUE].update(
|
||||
default_state
|
||||
)
|
||||
|
||||
alert_group_with_service = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group_with_service, raw_request_data={})
|
||||
make_alert_group_label_association(
|
||||
organization, alert_group_with_service, key_name=SERVICE_LABEL, value_name=TEST_SERVICE_VALUE
|
||||
)
|
||||
alert_group_created_signal.send(sender=alert_group_with_service.__class__, alert_group=alert_group_with_service)
|
||||
|
||||
# check alert_groups_total metric cache, get called args
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_firing, TEST_SERVICE_VALUE)
|
||||
|
||||
alert_group_with_service.resolve_by_user_or_backsync(user)
|
||||
get_called_arg_index_and_compare_results(expected_result_resolved, TEST_SERVICE_VALUE)
|
||||
|
||||
|
||||
@patch("apps.alerts.models.alert_group_log_record.tasks.send_update_log_report_signal.apply_async")
|
||||
|
|
@ -156,6 +193,7 @@ def test_update_metric_alert_groups_response_time_cache_on_action(
|
|||
make_alert,
|
||||
monkeypatch,
|
||||
make_metrics_cache_params,
|
||||
make_alert_group_label_association,
|
||||
):
|
||||
organization = make_organization(
|
||||
org_id=METRICS_TEST_ORG_ID,
|
||||
|
|
@ -175,21 +213,21 @@ def test_update_metric_alert_groups_response_time_cache_on_action(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: []},
|
||||
}
|
||||
}
|
||||
|
||||
metrics_cache = make_metrics_cache_params(alert_receive_channel.id, organization.id)
|
||||
monkeypatch.setattr(cache, "get", metrics_cache)
|
||||
|
||||
def get_called_arg_index_and_compare_results():
|
||||
def get_called_arg_index_and_compare_results(service_name=NO_SERVICE_VALUE):
|
||||
"""find index for related to the metric argument, that was set in cache"""
|
||||
for idx, called_arg in enumerate(mock_cache_set_called_args):
|
||||
if idx >= arg_idx and called_arg.args[0] == metric_alert_groups_response_time_key:
|
||||
response_time_values = called_arg.args[1][alert_receive_channel.id]["response_time"]
|
||||
expected_result_metric_alert_groups_response_time[alert_receive_channel.id].update(
|
||||
{"response_time": response_time_values}
|
||||
)
|
||||
response_time_values = called_arg.args[1][alert_receive_channel.id]["services"][service_name]
|
||||
expected_result_metric_alert_groups_response_time[alert_receive_channel.id]["services"][
|
||||
service_name
|
||||
] = response_time_values
|
||||
# response time values len always will be 1 here since cache is mocked and refreshed on every call
|
||||
assert len(response_time_values) == 1
|
||||
assert called_arg.args[1] == expected_result_metric_alert_groups_response_time
|
||||
|
|
@ -236,7 +274,19 @@ def test_update_metric_alert_groups_response_time_cache_on_action(
|
|||
arg_idx = get_called_arg_index_and_compare_results()
|
||||
|
||||
alert_group_3.silence_by_user_or_backsync(user, silence_delay=None)
|
||||
get_called_arg_index_and_compare_results()
|
||||
arg_idx = get_called_arg_index_and_compare_results()
|
||||
|
||||
# create alert group with service label and check metric cache is updated properly
|
||||
expected_result_metric_alert_groups_response_time[alert_receive_channel.id]["services"][NO_SERVICE_VALUE] = []
|
||||
|
||||
alert_group_with_service = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group_with_service, raw_request_data={})
|
||||
make_alert_group_label_association(
|
||||
organization, alert_group_with_service, key_name=SERVICE_LABEL, value_name=TEST_SERVICE_VALUE
|
||||
)
|
||||
assert_cache_was_not_changed_by_response_time_metric()
|
||||
alert_group_with_service.acknowledge_by_user_or_backsync(user)
|
||||
get_called_arg_index_and_compare_results(TEST_SERVICE_VALUE)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -296,10 +346,14 @@ def test_update_metrics_cache_on_update_integration(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
expected_result_metric_alert_groups_response_time = {
|
||||
|
|
@ -310,7 +364,7 @@ def test_update_metrics_cache_on_update_integration(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: []},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,10 +463,14 @@ def test_update_metrics_cache_on_update_team(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
expected_result_metric_alert_groups_response_time = {
|
||||
|
|
@ -423,7 +481,7 @@ def test_update_metrics_cache_on_update_team(
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
"services": {NO_SERVICE_VALUE: []},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -568,10 +626,14 @@ def test_metrics_add_integrations_to_cache(make_organization, make_alert_receive
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": firing,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: {
|
||||
"firing": firing,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _expected_alert_groups_response_time(alert_receive_channel, response_time=None):
|
||||
|
|
@ -585,7 +647,9 @@ def test_metrics_add_integrations_to_cache(make_organization, make_alert_receive
|
|||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": response_time,
|
||||
"services": {
|
||||
NO_SERVICE_VALUE: response_time,
|
||||
},
|
||||
}
|
||||
|
||||
# clear cache, add some data
|
||||
|
|
@ -612,3 +676,169 @@ def test_metrics_add_integrations_to_cache(make_organization, make_alert_receive
|
|||
alert_receive_channel1.id: _expected_alert_groups_response_time(alert_receive_channel1),
|
||||
alert_receive_channel2.id: _expected_alert_groups_response_time(alert_receive_channel2, response_time=[12]),
|
||||
}
|
||||
|
||||
|
||||
# todo:metrics: remove later when all cache is updated
|
||||
@patch("apps.alerts.models.alert_group_log_record.tasks.send_update_log_report_signal.apply_async")
|
||||
@patch("apps.alerts.tasks.send_alert_group_signal.alert_group_action_triggered_signal.send")
|
||||
@pytest.mark.django_db
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||
def test_update_metric_alert_groups_total_cache_on_action_backward_compatability(
|
||||
mocked_send_log_signal,
|
||||
mocked_action_signal_send,
|
||||
mock_apply_async,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_metrics_cache_params_old_version,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test update metric cache works properly with previous version of cache"""
|
||||
organization = make_organization(
|
||||
org_id=METRICS_TEST_ORG_ID,
|
||||
stack_slug=METRICS_TEST_INSTANCE_SLUG,
|
||||
stack_id=METRICS_TEST_INSTANCE_ID,
|
||||
)
|
||||
user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization, verbal_name=METRICS_TEST_INTEGRATION_NAME)
|
||||
|
||||
metric_alert_groups_total_key = get_metric_alert_groups_total_key(organization.id)
|
||||
|
||||
expected_result_metric_alert_groups_total = {
|
||||
alert_receive_channel.id: {
|
||||
"integration_name": alert_receive_channel.verbal_name,
|
||||
"team_name": "No team",
|
||||
"team_id": "no_team",
|
||||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
}
|
||||
}
|
||||
|
||||
expected_result_firing = {
|
||||
"firing": 1,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 0,
|
||||
}
|
||||
|
||||
expected_result_acked = {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 1,
|
||||
"resolved": 0,
|
||||
}
|
||||
|
||||
expected_result_resolved = {
|
||||
"firing": 0,
|
||||
"silenced": 0,
|
||||
"acknowledged": 0,
|
||||
"resolved": 1,
|
||||
}
|
||||
|
||||
metrics_cache = make_metrics_cache_params_old_version(alert_receive_channel.id, organization.id)
|
||||
monkeypatch.setattr(cache, "get", metrics_cache)
|
||||
|
||||
def get_called_arg_index_and_compare_results(update_expected_result):
|
||||
"""find index for the metric argument, that was set in cache"""
|
||||
for idx, called_arg in enumerate(mock_cache_set_called_args):
|
||||
if idx >= arg_idx and called_arg.args[0] == metric_alert_groups_total_key:
|
||||
expected_result_metric_alert_groups_total[alert_receive_channel.id].update(update_expected_result)
|
||||
assert called_arg.args[1] == expected_result_metric_alert_groups_total
|
||||
return idx + 1
|
||||
raise AssertionError
|
||||
|
||||
with patch("apps.metrics_exporter.tasks.cache.set") as mock_cache_set:
|
||||
arg_idx = 0
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
# this signal is normally called in get_or_create_grouping on create alert
|
||||
alert_group_created_signal.send(sender=alert_group.__class__, alert_group=alert_group)
|
||||
|
||||
# check alert_groups_total metric cache, get called args
|
||||
mock_cache_set_called_args = mock_cache_set.call_args_list
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_firing)
|
||||
|
||||
alert_group.acknowledge_by_user_or_backsync(user)
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_acked)
|
||||
|
||||
alert_group.resolve_by_user_or_backsync(user)
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_resolved)
|
||||
|
||||
alert_group.un_resolve_by_user_or_backsync(user)
|
||||
arg_idx = get_called_arg_index_and_compare_results(expected_result_firing)
|
||||
|
||||
|
||||
# todo:metrics: remove later when all cache is updated
|
||||
@patch("apps.alerts.models.alert_group_log_record.tasks.send_update_log_report_signal.apply_async")
|
||||
@patch("apps.alerts.tasks.send_alert_group_signal.alert_group_action_triggered_signal.send")
|
||||
@pytest.mark.django_db
|
||||
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||
def test_update_metric_alert_groups_response_time_cache_on_action_backward_compatability(
|
||||
mocked_send_log_signal,
|
||||
mocked_action_signal_send,
|
||||
mock_apply_async,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
monkeypatch,
|
||||
make_metrics_cache_params_old_version,
|
||||
):
|
||||
"""Test update metric cache works properly with previous version of cache"""
|
||||
organization = make_organization(
|
||||
org_id=METRICS_TEST_ORG_ID,
|
||||
stack_slug=METRICS_TEST_INSTANCE_SLUG,
|
||||
stack_id=METRICS_TEST_INSTANCE_ID,
|
||||
)
|
||||
user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization, verbal_name=METRICS_TEST_INTEGRATION_NAME)
|
||||
|
||||
metric_alert_groups_response_time_key = get_metric_alert_groups_response_time_key(organization.id)
|
||||
|
||||
expected_result_metric_alert_groups_response_time = {
|
||||
alert_receive_channel.id: {
|
||||
"integration_name": alert_receive_channel.verbal_name,
|
||||
"team_name": "No team",
|
||||
"team_id": "no_team",
|
||||
"org_id": organization.org_id,
|
||||
"slug": organization.stack_slug,
|
||||
"id": organization.stack_id,
|
||||
"response_time": [],
|
||||
}
|
||||
}
|
||||
|
||||
metrics_cache = make_metrics_cache_params_old_version(alert_receive_channel.id, organization.id)
|
||||
monkeypatch.setattr(cache, "get", metrics_cache)
|
||||
|
||||
def get_called_arg_index_and_compare_results():
|
||||
"""find index for related to the metric argument, that was set in cache"""
|
||||
for idx, called_arg in enumerate(mock_cache_set_called_args):
|
||||
if idx >= arg_idx and called_arg.args[0] == metric_alert_groups_response_time_key:
|
||||
response_time_values = called_arg.args[1][alert_receive_channel.id]["response_time"]
|
||||
expected_result_metric_alert_groups_response_time[alert_receive_channel.id].update(
|
||||
{"response_time": response_time_values}
|
||||
)
|
||||
# response time values len always will be 1 here since cache is mocked and refreshed on every call
|
||||
assert len(response_time_values) == 1
|
||||
assert called_arg.args[1] == expected_result_metric_alert_groups_response_time
|
||||
return idx + 1
|
||||
raise AssertionError
|
||||
|
||||
with patch("apps.metrics_exporter.tasks.cache.set") as mock_cache_set:
|
||||
arg_idx = 0
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# check alert_groups_response_time metric cache, get called args
|
||||
mock_cache_set_called_args = mock_cache_set.call_args_list
|
||||
|
||||
alert_group.acknowledge_by_user_or_backsync(user)
|
||||
arg_idx = get_called_arg_index_and_compare_results()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from rest_framework import status
|
|||
from rest_framework.test import APIClient
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.mobile_app.views import MobileAppGatewayView
|
||||
from apps.mobile_app.views import PROXY_REQUESTS_TIMEOUT, MobileAppGatewayView
|
||||
from common.cloud_auth_api.client import CloudAuthApiClient, CloudAuthApiException
|
||||
|
||||
DOWNSTREAM_BACKEND = "incident"
|
||||
|
|
@ -64,6 +64,7 @@ def test_mobile_app_gateway_properly_proxies_paths(
|
|||
data=b"",
|
||||
params={},
|
||||
headers=MOCK_DOWNSTREAM_HEADERS,
|
||||
timeout=PROXY_REQUESTS_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -118,6 +119,7 @@ def test_mobile_app_gateway_proxies_query_params(
|
|||
data=b"",
|
||||
params={"foo": "bar", "baz": "hello"},
|
||||
headers=MOCK_DOWNSTREAM_HEADERS,
|
||||
timeout=PROXY_REQUESTS_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -161,6 +163,7 @@ def test_mobile_app_gateway_properly_proxies_request_body(
|
|||
data=data.encode("utf-8"),
|
||||
params={},
|
||||
headers=MOCK_DOWNSTREAM_HEADERS,
|
||||
timeout=PROXY_REQUESTS_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -206,7 +209,7 @@ def test_mobile_app_gateway_supported_downstream_backends(
|
|||
(requests.exceptions.ConnectionError, (), status.HTTP_502_BAD_GATEWAY),
|
||||
(requests.exceptions.HTTPError, (), status.HTTP_502_BAD_GATEWAY),
|
||||
(requests.exceptions.TooManyRedirects, (), status.HTTP_502_BAD_GATEWAY),
|
||||
(requests.exceptions.Timeout, (), status.HTTP_502_BAD_GATEWAY),
|
||||
(requests.exceptions.Timeout, (), status.HTTP_504_GATEWAY_TIMEOUT),
|
||||
(requests.exceptions.JSONDecodeError, ("", "", 5), status.HTTP_400_BAD_REQUEST),
|
||||
(CloudAuthApiException, (403, "http://example.com"), status.HTTP_502_BAD_GATEWAY),
|
||||
],
|
||||
|
|
@ -317,6 +320,7 @@ def test_mobile_app_gateway_proxies_headers(
|
|||
"Authorization": f"Bearer {MOCK_AUTH_TOKEN}",
|
||||
"Content-Type": content_type_header,
|
||||
},
|
||||
timeout=PROXY_REQUESTS_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import enum
|
||||
import logging
|
||||
import time
|
||||
import typing
|
||||
|
||||
import requests
|
||||
|
|
@ -22,6 +23,9 @@ if typing.TYPE_CHECKING:
|
|||
from apps.user_management.models import Organization, User
|
||||
|
||||
|
||||
PROXY_REQUESTS_TIMEOUT = 5
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
|
@ -188,6 +192,7 @@ class MobileAppGatewayView(APIView):
|
|||
return f"{downstream_url}/{downstream_path}"
|
||||
|
||||
def _proxy_request(self, request: Request, *args, **kwargs) -> Response:
|
||||
request_start = time.perf_counter()
|
||||
downstream_backend = kwargs["downstream_backend"]
|
||||
downstream_path = kwargs["downstream_path"]
|
||||
method = request.method
|
||||
|
|
@ -209,17 +214,21 @@ class MobileAppGatewayView(APIView):
|
|||
data=request.body,
|
||||
params=request.query_params.dict(),
|
||||
headers=self._get_downstream_headers(request, downstream_backend, user),
|
||||
timeout=PROXY_REQUESTS_TIMEOUT, # set a timeout to prevent hanging
|
||||
)
|
||||
|
||||
final_status = downstream_response.status_code
|
||||
logger.info(f"Successfully proxied {log_msg_common}")
|
||||
return Response(status=downstream_response.status_code, data=downstream_response.json())
|
||||
except (
|
||||
requests.exceptions.RequestException,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
requests.exceptions.Timeout,
|
||||
CloudAuthApiException,
|
||||
) as e:
|
||||
if isinstance(e, requests.exceptions.JSONDecodeError):
|
||||
final_status = status.HTTP_400_BAD_REQUEST
|
||||
elif isinstance(e, requests.exceptions.Timeout):
|
||||
final_status = status.HTTP_504_GATEWAY_TIMEOUT
|
||||
else:
|
||||
final_status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
|
@ -235,6 +244,14 @@ class MobileAppGatewayView(APIView):
|
|||
exc_info=True,
|
||||
)
|
||||
return Response(status=final_status)
|
||||
finally:
|
||||
request_end = time.perf_counter()
|
||||
seconds = request_end - request_start
|
||||
logging.info(
|
||||
f"outbound latency={str(seconds)} status={final_status} "
|
||||
f"method={method.upper()} url={downstream_url} "
|
||||
f"slow={int(seconds > settings.SLOW_THRESHOLD_SECONDS)} "
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from datetime import timedelta
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import httpretty
|
||||
|
|
@ -348,7 +349,11 @@ def test_execute_webhook_ok_forward_all(
|
|||
other_user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(
|
||||
alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True, acknowledged_by=user.pk
|
||||
alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
acknowledged_by=user.pk,
|
||||
acknowledged_by_user=user,
|
||||
)
|
||||
for _ in range(3):
|
||||
make_user_notification_policy_log_record(
|
||||
|
|
@ -410,6 +415,12 @@ def test_execute_webhook_ok_forward_all(
|
|||
"name": webhook.name,
|
||||
"labels": {},
|
||||
},
|
||||
"alert_group_acknowledged_by": {
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
"alert_group_resolved_by": None,
|
||||
}
|
||||
expected_call = call(
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
|
|
@ -427,6 +438,118 @@ def test_execute_webhook_ok_forward_all(
|
|||
assert log.url == "https://something/{}/".format(alert_group.public_primary_key)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_ok_forward_all_resolved(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_user_notification_policy_log_record,
|
||||
make_custom_webhook,
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization)
|
||||
notified_user = make_user_for_organization(organization)
|
||||
other_user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(
|
||||
alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
acknowledged_by=user.pk,
|
||||
acknowledged_by_user=user,
|
||||
resolved=True,
|
||||
resolved_at=timezone.now() + timedelta(hours=2),
|
||||
resolved_by=user.pk,
|
||||
resolved_by_user=user,
|
||||
)
|
||||
for _ in range(3):
|
||||
make_user_notification_policy_log_record(
|
||||
author=notified_user,
|
||||
alert_group=alert_group,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS,
|
||||
)
|
||||
make_user_notification_policy_log_record(
|
||||
author=other_user,
|
||||
alert_group=alert_group,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
|
||||
)
|
||||
webhook = make_custom_webhook(
|
||||
organization=organization,
|
||||
url="https://something/{{ alert_group_id }}/",
|
||||
http_method="POST",
|
||||
trigger_type=Webhook.TRIGGER_RESOLVE,
|
||||
forward_all=True,
|
||||
)
|
||||
|
||||
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
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_RESOLVE)
|
||||
|
||||
assert mock_requests.post.called
|
||||
expected_data = {
|
||||
"event": {
|
||||
"type": "resolve",
|
||||
"time": alert_group.resolved_at.isoformat(),
|
||||
},
|
||||
"user": {
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
"integration": {
|
||||
"id": alert_receive_channel.public_primary_key,
|
||||
"type": alert_receive_channel.integration,
|
||||
"name": alert_receive_channel.short_name,
|
||||
"team": None,
|
||||
"labels": {},
|
||||
},
|
||||
"notified_users": [
|
||||
{
|
||||
"id": notified_user.public_primary_key,
|
||||
"username": notified_user.username,
|
||||
"email": notified_user.email,
|
||||
}
|
||||
],
|
||||
"alert_group": {**IncidentSerializer(alert_group).data, "labels": {}},
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"alert_payload": "",
|
||||
"users_to_be_notified": [],
|
||||
"webhook": {
|
||||
"id": webhook.public_primary_key,
|
||||
"name": webhook.name,
|
||||
"labels": {},
|
||||
},
|
||||
"alert_group_acknowledged_by": {
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
"alert_group_resolved_by": {
|
||||
"id": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
}
|
||||
expected_call = call(
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
timeout=TIMEOUT,
|
||||
headers={},
|
||||
json=expected_data,
|
||||
)
|
||||
assert mock_requests.post.call_args == expected_call
|
||||
# check logs
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.trigger_type == Webhook.TRIGGER_RESOLVE
|
||||
assert log.status_code == 200
|
||||
assert log.content == json.dumps(mock_response.json())
|
||||
assert json.loads(log.request_data) == expected_data
|
||||
assert log.url == "https://something/{}/".format(alert_group.public_primary_key)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_using_responses_data(
|
||||
make_organization,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ def serialize_event(event, alert_group, user, webhook, responses=None):
|
|||
for user in set(notification.author for notification in alert_group.sent_notifications)
|
||||
],
|
||||
"users_to_be_notified": _extract_users_from_escalation_snapshot(alert_group.escalation_snapshot),
|
||||
"alert_group_acknowledged_by": _serialize_event_user(alert_group.acknowledged_by_user),
|
||||
"alert_group_resolved_by": _serialize_event_user(alert_group.resolved_by_user),
|
||||
}
|
||||
if responses:
|
||||
data["responses"] = responses
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class DateRangeFilterMixin:
|
|||
if not value:
|
||||
return None, None
|
||||
|
||||
date_entries = value.split("/")
|
||||
date_entries = value.split("_")
|
||||
|
||||
if len(date_entries) != 2:
|
||||
raise BadRequest(detail="Invalid range value")
|
||||
|
|
|
|||
|
|
@ -95,3 +95,17 @@ if settings.PYROSCOPE_PROFILER_ENABLED:
|
|||
detect_subprocesses=True, # detect subprocesses started by the main process; default is False
|
||||
tags={"type": "celery", "celery_worker": os.environ.get("CELERY_WORKER_QUEUE", "no_queue_specified")},
|
||||
)
|
||||
|
||||
|
||||
if settings.LOG_CELERY_TASK_ARGUMENTS:
|
||||
"""
|
||||
Note: Task ID and name are already provided in TaskFormatter prefix, arguments get listed in message
|
||||
"""
|
||||
|
||||
@celery.signals.task_prerun.connect
|
||||
def log_started_task_arguments(sender=None, task_id=None, task=None, args=None, kwargs=None, **extras):
|
||||
logger.info(f"task started args={args}")
|
||||
|
||||
@celery.signals.task_postrun.connect
|
||||
def log_finished_task_arguments(sender=None, task_id=None, task=None, args=None, kwargs=None, **extras):
|
||||
logger.info(f"task finished args={args}")
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ if OTEL_TRACING_ENABLED:
|
|||
MIDDLEWARE.insert(0, "engine.middlewares.LogRequestHeadersMiddleware")
|
||||
|
||||
LOG_REQUEST_ID_HEADER = "HTTP_X_CLOUD_TRACE_CONTEXT"
|
||||
|
||||
LOG_CELERY_TASK_ARGUMENTS = getenv_boolean("LOG_CELERY_TASK_ARGUMENTS", default=True)
|
||||
|
||||
log_fmt = "source=engine:app google_trace_id=%(request_id)s logger=%(name)s %(message)s"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ FROM node:18.16.0-alpine
|
|||
WORKDIR /etc/app
|
||||
ENV PATH /etc/app/node_modules/.bin:$PATH
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
CMD ["yarn", "start"]
|
||||
|
|
@ -3,7 +3,7 @@ import { clickButton, generateRandomValidLabel, openDropdown } from '../utils/fo
|
|||
import { openCreateIntegrationModal } from '../utils/integrations';
|
||||
import { goToOnCallPage } from '../utils/navigation';
|
||||
|
||||
test('New label keys and labels can be created', async ({ adminRolePage }) => {
|
||||
test('New label keys and labels can be created @expensive', async ({ adminRolePage }) => {
|
||||
const { page } = adminRolePage;
|
||||
await goToOnCallPage(page, 'integrations');
|
||||
await openCreateIntegrationModal(page);
|
||||
|
|
|
|||
12
grafana-plugin/e2e-tests/settings/tabs.test.ts
Normal file
12
grafana-plugin/e2e-tests/settings/tabs.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { test, expect } from '../fixtures';
|
||||
import { goToOnCallPage } from '../utils/navigation';
|
||||
|
||||
test(`tab query param is used to show proper page tab`, async ({ adminRolePage }) => {
|
||||
const { page } = adminRolePage;
|
||||
goToOnCallPage(page, `settings`, { tab: 'ChatOps' });
|
||||
|
||||
const tab = page.getByRole('tab', { name: 'Chat Ops' });
|
||||
const isSelected = await tab.getAttribute('aria-selected');
|
||||
|
||||
expect(isSelected).toBe('true');
|
||||
});
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { KeyValue } from '@grafana/data';
|
||||
import type { Page } from '@playwright/test';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { BASE_URL } from './constants';
|
||||
|
||||
|
|
@ -17,8 +19,9 @@ const _goToPage = async (page: Page, url = '') => page.goto(`${BASE_URL}${url}`)
|
|||
|
||||
export const goToGrafanaPage = async (page: Page, url = '') => _goToPage(page, url);
|
||||
|
||||
export const goToOnCallPage = async (page: Page, onCallPage: OnCallPage) => {
|
||||
await _goToPage(page, `/a/grafana-oncall-app/${onCallPage}`);
|
||||
export const goToOnCallPage = async (page: Page, onCallPage: OnCallPage, queryParams?: KeyValue) => {
|
||||
const queryParamsString = queryParams ? `?${qs.stringify(queryParams)}` : '';
|
||||
await _goToPage(page, `/a/grafana-oncall-app/${onCallPage}${queryParamsString}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@
|
|||
"stylelint": "^13.13.1",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"throttle-debounce": "^2.1.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tslib": "2.5.3"
|
||||
},
|
||||
"packageManager": "yarn@1.22.21"
|
||||
|
|
|
|||
|
|
@ -23,17 +23,15 @@ export const getCardButtonStyles = (theme: GrafanaTheme2) => {
|
|||
`,
|
||||
|
||||
rootSelected: css`
|
||||
{
|
||||
&::before {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-image: linear-gradient(270deg, #f55f3e 0%, #f83 100%);
|
||||
}
|
||||
&::before {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-image: ${theme.colors.gradients.brandVertical};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Tag } from 'components/Tag/Tag';
|
||||
import { Tag, TagColor } from 'components/Tag/Tag';
|
||||
import { Text } from 'components/Text/Text';
|
||||
|
||||
interface IntegrationTagProps {
|
||||
|
|
@ -15,7 +14,7 @@ export const IntegrationTag: FC<IntegrationTagProps> = ({ children }) => {
|
|||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Tag className={styles.tag}>
|
||||
<Tag className={styles.tag} color={TagColor.SECONDARY}>
|
||||
<Text type="primary" size="small" className={styles.radius}>
|
||||
{children}
|
||||
</Text>
|
||||
|
|
@ -23,11 +22,9 @@ export const IntegrationTag: FC<IntegrationTagProps> = ({ children }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
export const getStyles = () => ({
|
||||
tag: css({
|
||||
height: '25px',
|
||||
background: theme.colors.background.secondary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
radius: css({
|
||||
borderRadius: '4px',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import React, { ComponentProps, FC } from 'react';
|
||||
|
||||
import { HorizontalGroup, Icon, Tooltip } from '@grafana/ui';
|
||||
|
||||
interface NonExistentUserNameProps {
|
||||
justify?: ComponentProps<typeof HorizontalGroup>['justify'];
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
const NonExistentUserName: FC<NonExistentUserNameProps> = ({ justify = 'space-between', userName }) => (
|
||||
<HorizontalGroup justify={justify}>
|
||||
<span>Missing user</span>
|
||||
<Tooltip content={`${userName || 'User'} } is not found or doesn't have permission to participate in the rotation`}>
|
||||
<Icon name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
|
||||
export default NonExistentUserName;
|
||||
|
|
@ -7,6 +7,7 @@ import { observer } from 'mobx-react';
|
|||
import moment from 'moment-timezone';
|
||||
import { SortableElement } from 'react-sortable-hoc';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import { getLabelBackgroundTextColorObject } from 'styles/utils.styles';
|
||||
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { Text } from 'components/Text/Text';
|
||||
|
|
@ -28,7 +29,6 @@ import { UserGroup } from 'models/user_group/user_group.types';
|
|||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { SelectOption, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import { DragHandle } from './DragHandle';
|
||||
|
|
@ -80,12 +80,14 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
(escalationOption: EscalationPolicyOption) => escalationOption.value === step
|
||||
);
|
||||
|
||||
const { textColor: itemTextColor } = getLabelBackgroundTextColorObject('green', this.props.theme);
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={id}
|
||||
contentClassName={cx(this.styles.root)}
|
||||
number={number}
|
||||
textColor={isDisabled ? getVar('--tag-text-success') : undefined}
|
||||
textColor={isDisabled ? itemTextColor : undefined}
|
||||
backgroundClassName={backgroundClassName}
|
||||
backgroundHexNumber={backgroundHexNumber}
|
||||
>
|
||||
|
|
@ -455,6 +457,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
return (
|
||||
<WithPermissionControlTooltip key="notify_to_team_members" userAction={UserActions.EscalationChainsWrite}>
|
||||
<GSelect<GrafanaTeam>
|
||||
showSearch
|
||||
disabled={isDisabled}
|
||||
items={grafanaTeamStore.items}
|
||||
fetchItemsFn={grafanaTeamStore.updateItems}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const getStyles = () => {
|
|||
return {
|
||||
root: css`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`,
|
||||
|
||||
avatar: css`
|
||||
|
|
|
|||
|
|
@ -17,27 +17,6 @@ export const getScheduleQualityStyles = (_theme: GrafanaTheme2) => {
|
|||
tag: css`
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
|
||||
&--danger {
|
||||
// TODO: emotionjs
|
||||
background-color: var(--tag-background-danger);
|
||||
color: var(--tag-text-danger);
|
||||
border: 1px solid var(--tag-border-danger);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
// TODO: emotionjs
|
||||
background-color: var(--tag-background-warning);
|
||||
color: var(--tag-text-warning);
|
||||
border: 1px solid var(--tag-border-warning);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
// TODO: emotionjs
|
||||
background-color: var(--tag-background-success);
|
||||
color: var(--tag-text-success);
|
||||
border: 1px solid var(--tag-border-success);
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import React, { FC, useEffect } from 'react';
|
|||
import { cx } from '@emotion/css';
|
||||
import { Tooltip, VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { bem, getUtilStyles } from 'styles/utils.styles';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails';
|
||||
import { Tag } from 'components/Tag/Tag';
|
||||
import { Tag, TagColor } from 'components/Tag/Tag';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
|
||||
import { Schedule, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
|
|
@ -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, bem(styles.tag, getTagSeverity()))}>
|
||||
<Tag className={cx(styles.tag)} color={getTagSeverity()}>
|
||||
Quality: <strong>{getScheduleQualityString(quality.total_score)}</strong>
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -115,11 +115,11 @@ export const ScheduleQuality: FC<ScheduleQualityProps> = observer(({ schedule })
|
|||
|
||||
function getTagSeverity() {
|
||||
if (quality?.total_score < 20) {
|
||||
return 'danger';
|
||||
return TagColor.ERROR_LABEL;
|
||||
}
|
||||
if (quality?.total_score < 60) {
|
||||
return 'warning';
|
||||
return TagColor.WARNING_LABEL;
|
||||
}
|
||||
return 'primary';
|
||||
return TagColor.SUCCESS_LABEL;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import React, { FC } from 'react';
|
|||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { bem } from 'styles/utils.styles';
|
||||
import { bem, getLabelCss } from 'styles/utils.styles';
|
||||
|
||||
interface TagProps {
|
||||
color?: string;
|
||||
color?: string | TagColor;
|
||||
className?: string;
|
||||
border?: string;
|
||||
text?: string;
|
||||
|
|
@ -16,43 +16,89 @@ interface TagProps {
|
|||
size?: 'small' | 'medium';
|
||||
}
|
||||
|
||||
export enum TagColor {
|
||||
SUCCESS = 'success',
|
||||
WARNING = 'warning',
|
||||
ERROR = 'error',
|
||||
SECONDARY = 'secondary',
|
||||
INFO = 'info',
|
||||
|
||||
SUCCESS_LABEL = 'successLabel',
|
||||
WARNING_LABEL = 'warningLabel',
|
||||
ERROR_LABEL = 'errorLabel',
|
||||
}
|
||||
|
||||
export const Tag: FC<TagProps> = (props) => {
|
||||
const { color, children, className, onClick, size = 'medium' } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const { children, color, text, className, border, onClick, size = 'medium' } = props;
|
||||
const style: React.CSSProperties = {
|
||||
backgroundColor: color,
|
||||
color: text,
|
||||
border,
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
style={style}
|
||||
className={cx(styles.root, bem(styles.root, size), className)}
|
||||
className={cx(styles.root, bem(styles.root, size), getMatchingClass(), className)}
|
||||
onClick={onClick}
|
||||
ref={props.forwardedRef}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (_theme: GrafanaTheme2) => {
|
||||
return {
|
||||
root: css`
|
||||
border-radius: 2px;
|
||||
line-height: 100%;
|
||||
padding: 5px 8px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
function getMatchingClass() {
|
||||
return styles[color]
|
||||
? // Either pick a defined style already or create one on the spot with the passed bgColor
|
||||
styles[color]
|
||||
: css`
|
||||
background-color: ${color};
|
||||
color: text;
|
||||
`;
|
||||
}
|
||||
|
||||
size: css`
|
||||
&--small {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
}
|
||||
`,
|
||||
};
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
root: css`
|
||||
border-radius: 2px;
|
||||
line-height: 100%;
|
||||
padding: 5px 8px;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
size: css`
|
||||
&--small {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
}
|
||||
`,
|
||||
|
||||
success: css`
|
||||
background-color: ${theme.colors.success.main};
|
||||
border: solid 1px ${theme.colors.success.main};
|
||||
color: ${theme.isDark ? '#fff' : theme.colors.success.contrastText};
|
||||
`,
|
||||
warning: css`
|
||||
background-color: ${theme.colors.warning.main};
|
||||
border: solid 1px ${theme.colors.warning.main};
|
||||
color: #fff;
|
||||
`,
|
||||
error: css`
|
||||
background-color: ${theme.colors.error.main};
|
||||
border: solid 1px ${theme.colors.error.main};
|
||||
color: ${theme.isDark ? '#fff' : theme.colors.error.contrastText};
|
||||
`,
|
||||
secondary: css`
|
||||
background-color: ${theme.colors.secondary.main};
|
||||
border: solid 1px ${theme.colors.secondary.main};
|
||||
color: ${theme.isDark ? '#fff' : theme.colors.secondary.contrastText};
|
||||
`,
|
||||
info: css`
|
||||
background-color: ${theme.colors.primary.main};
|
||||
border: solid 1px ${theme.colors.primary.main};
|
||||
color: ${theme.isDark ? '#fff' : theme.colors.info.contrastText};
|
||||
`,
|
||||
|
||||
successLabel: getLabelCss('green', theme),
|
||||
warningLabel: getLabelCss('orange', theme),
|
||||
errorLabel: getLabelCss('red', theme),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ export const getTextStyles = (theme: GrafanaTheme2) => {
|
|||
root: css`
|
||||
display: inline;
|
||||
|
||||
&:hover .icon-button {
|
||||
display: inline-block;
|
||||
&:hover [data-emotion='iconButton'] {
|
||||
display: inline-flex;
|
||||
}
|
||||
`,
|
||||
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ export const Text: TextInterface = (props) => {
|
|||
styles.root,
|
||||
styles.text,
|
||||
{ [styles.maxWidth]: Boolean(maxWidth) },
|
||||
{ [bem(styles.text, `${type}`)]: true },
|
||||
{ [bem(styles.text, `${size}`)]: true },
|
||||
{ [bem(styles.text, type)]: true },
|
||||
{ [bem(styles.text, size)]: true },
|
||||
{ [bem(styles.text, `strong`)]: strong },
|
||||
{ [bem(styles.text, `underline`)]: underline },
|
||||
{ [bem(styles.text, 'clickable')]: clickable },
|
||||
|
|
@ -109,6 +109,7 @@ export const Text: TextInterface = (props) => {
|
|||
className={styles.iconButton}
|
||||
tooltip="Edit"
|
||||
tooltipPlacement="top"
|
||||
data-emotion="iconButton"
|
||||
name="pen"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -124,6 +125,7 @@ export const Text: TextInterface = (props) => {
|
|||
className={styles.iconButton}
|
||||
tooltip="Copy to clipboard"
|
||||
tooltipPlacement="top"
|
||||
data-emotion="iconButton"
|
||||
name="copy"
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { getLabelCss } from 'styles/utils.styles';
|
||||
|
||||
export const getTooltipBadgeStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
primary: getLabelCss('blue', theme),
|
||||
warning: getLabelCss('orange', theme),
|
||||
success: getLabelCss('green', theme),
|
||||
danger: getLabelCss('red', theme),
|
||||
|
||||
secondary: css`
|
||||
background: ${theme.colors.background.secondary};
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
color: ${theme.colors.text.primary};
|
||||
`,
|
||||
|
||||
element: css`
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
|
@ -10,36 +22,6 @@ export const getTooltipBadgeStyles = (theme: GrafanaTheme2) => {
|
|||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
|
||||
&--primary {
|
||||
background: var(--tag-background-primary);
|
||||
border: 1px solid var(--tag-border-primary);
|
||||
color: var(--tag-text-primary);
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: ${theme.colors.background.secondary};
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
color: ${theme.colors.text.primary};
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: var(--tag-background-warning);
|
||||
border: 1px solid var(--tag-border-warning);
|
||||
color: var(--tag-text-warning);
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: var(--tag-background-success);
|
||||
border: 1px solid var(--tag-border-success);
|
||||
color: var(--tag-text-success);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--tag-background-danger);
|
||||
border: 1px solid var(--tag-border-danger);
|
||||
color: var(--tag-text-danger);
|
||||
}
|
||||
|
||||
&--padding {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,12 +55,7 @@ export const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
styles.element,
|
||||
{ [bem(styles.element, `${borderType}`)]: true },
|
||||
{ [bem(styles.element, 'padding')]: addPadding },
|
||||
className
|
||||
)}
|
||||
className={cx(styles.element, styles[borderType], { [bem(styles.element, 'padding')]: addPadding }, className)}
|
||||
onMouseEnter={onHover}
|
||||
{...(testId ? { 'data-testid': testId } : {})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -38,7 +38,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -78,7 +78,7 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -96,7 +96,7 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -136,7 +136,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -154,7 +154,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -194,7 +194,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -212,7 +212,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -252,7 +252,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -270,7 +270,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -72,7 +72,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -106,7 +106,7 @@ exports[`AddResponders should render properly in create mode 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -159,7 +159,7 @@ exports[`AddResponders should render properly in update mode 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -212,7 +212,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
my test user3
|
||||
</span>
|
||||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
my test user
|
||||
</span>
|
||||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
my test user2
|
||||
</span>
|
||||
|
|
@ -664,7 +664,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-9om60p"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
<a
|
||||
class="learn-more-link"
|
||||
|
|
@ -673,7 +673,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
|
|
|
|||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium responder-name css-1287p17"
|
||||
>
|
||||
johnsmith
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { VerticalGroup } from '@grafana/ui';
|
||||
import { VerticalGroup, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { Timeline } from 'components/Timeline/Timeline';
|
||||
import { MSTeamsConnector } from 'containers/AlertRules/parts/connectors/MSTeamsConnector';
|
||||
|
|
@ -9,7 +9,6 @@ import { TelegramConnector } from 'containers/AlertRules/parts/connectors/Telegr
|
|||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
interface ChatOpsConnectorsProps {
|
||||
channelFilterId: ChannelFilter['id'];
|
||||
|
|
@ -20,6 +19,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
const { channelFilterId, showLineNumber = true } = props;
|
||||
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
const { organizationStore, telegramChannelStore, msteamsChannelStore } = store;
|
||||
|
||||
const isSlackInstalled = Boolean(organizationStore.currentOrganization?.slack_team_identity);
|
||||
|
|
@ -37,7 +37,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Timeline.Item number={0} backgroundHexNumber={getVar('--tag-secondary')} isDisabled={!showLineNumber}>
|
||||
<Timeline.Item number={0} backgroundHexNumber={theme.colors.secondary.main} isDisabled={!showLineNumber}>
|
||||
<VerticalGroup>
|
||||
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
|
||||
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import React, { ReactElement, useCallback, useEffect } from 'react';
|
|||
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LoadingPlaceholder, Select, useStyles2 } from '@grafana/ui';
|
||||
import { LoadingPlaceholder, Select, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import { getLabelBackgroundTextColorObject } from 'styles/utils.styles';
|
||||
|
||||
import { EscalationPolicy, EscalationPolicyProps } from 'components/Policy/EscalationPolicy';
|
||||
import { SortableList } from 'components/SortableList/SortableList';
|
||||
|
|
@ -14,7 +15,6 @@ import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/W
|
|||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { EscalationPolicyOption } from 'models/escalation_policy/escalation_policy.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import styles from './EscalationChainSteps.module.css';
|
||||
|
|
@ -41,6 +41,7 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps)
|
|||
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
|
||||
const { escalationPolicyStore } = store;
|
||||
|
||||
|
|
@ -74,6 +75,7 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps)
|
|||
|
||||
const escalationPolicyIds = escalationPolicyStore.escalationChainToEscalationPolicy[id];
|
||||
const isSlackInstalled = Boolean(store.organizationStore.currentOrganization?.slack_team_identity);
|
||||
const { bgColor: successBgColor, textColor: successTextColor } = getLabelBackgroundTextColorObject('green', theme);
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
|
|
@ -124,8 +126,8 @@ export const EscalationChainSteps = observer((props: EscalationChainStepsProps)
|
|||
{!isDisabled && (
|
||||
<Timeline.Item
|
||||
number={(escalationPolicyIds?.length || 0) + offset + 1}
|
||||
backgroundHexNumber={isDisabled ? getVar('--tag-background-success') : getVar('--tag-secondary')}
|
||||
textColor={isDisabled ? getVar('--tag-text-success') : undefined}
|
||||
backgroundHexNumber={isDisabled ? successBgColor : theme.colors.secondary.main}
|
||||
textColor={isDisabled ? successTextColor : undefined}
|
||||
>
|
||||
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const IntegrationFormContainer = observer((props: IntegrationFormContaine
|
|||
const [filterValue, setFilterValue] = useState('');
|
||||
const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState<ApiSchemas['AlertReceiveChannelIntegrationOptions']>(undefined);
|
||||
const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new');
|
||||
const [showIntegrationsListDrawer, setshowIntegrationsListDrawer] = useState(id === 'new');
|
||||
|
||||
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export const IntegrationFormContainer = observer((props: IntegrationFormContaine
|
|||
|
||||
return (
|
||||
<>
|
||||
{showIntegrarionsListDrawer && (
|
||||
{showIntegrationsListDrawer && (
|
||||
<Drawer scrollableContent title="New Integration" onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
|
|
@ -79,7 +79,7 @@ export const IntegrationFormContainer = observer((props: IntegrationFormContaine
|
|||
</div>
|
||||
</Drawer>
|
||||
)}
|
||||
{(showNewIntegrationForm || !showIntegrarionsListDrawer) && (
|
||||
{(showNewIntegrationForm || !showIntegrationsListDrawer) && (
|
||||
<Drawer scrollableContent title={getTitle()} onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
|
|
@ -100,13 +100,13 @@ export const IntegrationFormContainer = observer((props: IntegrationFormContaine
|
|||
|
||||
function onBackClick(): void {
|
||||
setShowNewIntegrationForm(false);
|
||||
setShowIntegrarionsListDrawer(true);
|
||||
setshowIntegrationsListDrawer(true);
|
||||
}
|
||||
|
||||
function onBlockClick(option: ApiSchemas['AlertReceiveChannelIntegrationOptions']): void {
|
||||
setSelectedOption(option);
|
||||
setShowNewIntegrationForm(true);
|
||||
setShowIntegrarionsListDrawer(false);
|
||||
setshowIntegrationsListDrawer(false);
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -54,7 +54,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -84,7 +84,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -109,7 +109,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -171,7 +171,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -180,7 +180,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -210,7 +210,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -235,7 +235,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -297,7 +297,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -306,7 +306,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -336,7 +336,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -361,7 +361,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -388,7 +388,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`]
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Please connect Grafana Cloud OnCall to use the mobile app
|
||||
</span>
|
||||
|
|
@ -438,7 +438,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
class="css-1x53p5e css-1x53p5e--bordered css-1x53p5e--shadowed css-1x53p5e--withBackGround container__box"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
There was an error disconnecting your mobile app. Please try again.
|
||||
</span>
|
||||
|
|
@ -454,7 +454,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -463,7 +463,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -493,7 +493,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -518,7 +518,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -554,7 +554,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
class="css-1x53p5e css-1x53p5e--bordered css-1x53p5e--shadowed css-1x53p5e--withBackGround container__box"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
There was an error fetching your QR code. Please try again.
|
||||
</span>
|
||||
|
|
@ -570,7 +570,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -579,7 +579,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -609,7 +609,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -634,7 +634,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -19,7 +19,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -49,7 +49,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -74,7 +74,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exports[`LinkLoginButton it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
>
|
||||
Sign in via deeplink
|
||||
</span>
|
||||
|
|
@ -19,7 +19,7 @@ exports[`LinkLoginButton it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Make sure to have the app installed
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonDa
|
|||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonDa
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
@ -47,7 +47,7 @@ exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonDa
|
|||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
|
|
@ -117,7 +117,7 @@ exports[`PluginConfigPage If onCallApiUrl is set, and updatePluginStatus returns
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh nooo a plugin connection error
|
||||
</span>
|
||||
|
|
@ -169,7 +169,7 @@ exports[`PluginConfigPage It doesn't make any network calls if the plugin config
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
|
|
@ -224,7 +224,7 @@ exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnec
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
|
|
@ -279,7 +279,7 @@ exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnec
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, some-other-license)
|
||||
</span>
|
||||
|
|
@ -336,7 +336,7 @@ exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnec
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh noooo a sync issue
|
||||
</span>
|
||||
|
|
@ -391,7 +391,7 @@ exports[`PluginConfigPage Plugin reset: successful - false 1`] = `
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
There was an error resetting your plugin, try again.
|
||||
</span>
|
||||
|
|
@ -453,7 +453,7 @@ exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
|
|||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
@ -479,7 +479,7 @@ exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
|
|||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ exports[`ConfigurationForm It doesn't allow the user to submit if the URL is inv
|
|||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ exports[`ConfigurationForm It doesn't allow the user to submit if the URL is inv
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
@ -40,7 +40,7 @@ exports[`ConfigurationForm It doesn't allow the user to submit if the URL is inv
|
|||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
|
|
@ -125,7 +125,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
@ -151,7 +151,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
|
|
@ -195,7 +195,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
</div>
|
||||
<pre>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh nooo there was an error from the OnCall API
|
||||
</span>
|
||||
|
|
@ -204,7 +204,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
class="css-1x53p5e css-1x53p5e--withBackGround info-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Need help?
|
||||
<br />
|
||||
|
|
@ -216,7 +216,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
#grafana-oncall
|
||||
</span>
|
||||
|
|
@ -232,7 +232,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
@ -247,7 +247,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ exports[`StatusMessageBlock It renders properly 1`] = `
|
|||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1d6rs0l"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
helloooo
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { convertRelativeToAbsoluteDate } from 'utils/datetime';
|
||||
|
||||
import { FilterOption } from './RemoteFilters.types';
|
||||
|
||||
const normalize = (value: any) => {
|
||||
|
|
@ -33,8 +31,6 @@ export function parseFilters(
|
|||
value = [rawValue];
|
||||
}
|
||||
value = value.map(normalize);
|
||||
} else if (filterOption.type === 'daterange') {
|
||||
value = convertRelativeToAbsoluteDate(value);
|
||||
} else if ((filterOption.type === 'boolean' && rawValue === '') || rawValue === 'true') {
|
||||
value = true;
|
||||
} else if (rawValue === 'false') {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { SelectOption, WithStoreProps } from 'state/types';
|
|||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { LocationHelper } from 'utils/LocationHelper';
|
||||
import { PAGE } from 'utils/consts';
|
||||
import { convertTimerangeToFilterValue, getValueForDateRangeFilterType } from 'utils/datetime';
|
||||
import { allFieldsEmpty } from 'utils/utils';
|
||||
|
||||
import { parseFilters } from './RemoteFilters.helpers';
|
||||
|
|
@ -314,22 +315,11 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
);
|
||||
|
||||
case 'daterange':
|
||||
const dates = values[filter.name] ? values[filter.name].split('/') : undefined;
|
||||
|
||||
const value = {
|
||||
from: dates ? moment(dates[0] + 'Z') : undefined,
|
||||
to: dates ? moment(dates[1] + 'Z') : undefined,
|
||||
raw: {
|
||||
from: dates ? dates[0] : '',
|
||||
to: dates ? dates[1] : '',
|
||||
},
|
||||
};
|
||||
const value = getValueForDateRangeFilterType(values[filter.name]);
|
||||
|
||||
return (
|
||||
<TimeRangeInput
|
||||
timeZone={moment.tz.guess()}
|
||||
autoFocus={autoFocus}
|
||||
// @ts-ignore
|
||||
value={value}
|
||||
onChange={this.getDateRangeFilterChangeHandler(filter.name)}
|
||||
hideTimeZone
|
||||
|
|
@ -387,9 +377,7 @@ class _RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
|
||||
getDateRangeFilterChangeHandler = (name: FilterOption['name']) => {
|
||||
return (timeRange: TimeRange) => {
|
||||
const value =
|
||||
timeRange.from.utc().format('YYYY-MM-DDTHH:mm:ss') + '/' + timeRange.to.utc().format('YYYY-MM-DDTHH:mm:ss');
|
||||
|
||||
const value = convertTimerangeToFilterValue(timeRange);
|
||||
this.onFiltersValueChange(name, value);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IconButton, VerticalGroup, HorizontalGroup, Field, Button } from '@grafana/ui';
|
||||
import { IconButton, VerticalGroup, HorizontalGroup, Field, Button, useTheme2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import Draggable from 'react-draggable';
|
||||
|
|
@ -15,7 +15,7 @@ import { Schedule, Shift } from 'models/schedule/schedule.types';
|
|||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getCoords, getVar, waitForElement } from 'utils/DOM';
|
||||
import { getCoords, waitForElement } from 'utils/DOM';
|
||||
import { GRAFANA_HEADER_HEIGHT } from 'utils/consts';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
||||
|
|
@ -48,10 +48,11 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
shiftId,
|
||||
shiftStart: propsShiftStart = dayjs().startOf('day').add(1, 'day'),
|
||||
shiftEnd: propsShiftEnd,
|
||||
shiftColor = getVar('--tag-warning'),
|
||||
shiftColor: shiftColorProp,
|
||||
} = props;
|
||||
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
|
||||
const [rotationName, setRotationName] = useState<string>(shiftId === 'new' ? 'Override' : 'Update override');
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
const shiftColor = shiftColorProp || theme.colors.warning.main;
|
||||
|
||||
const updateShiftStart = useCallback(
|
||||
(value) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
import { COLORS } from 'styles/utils.styles';
|
||||
|
||||
import NonExistentUserName from 'components/NonExistentUserName/NonExistentUserName';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WorkingHours } from 'components/WorkingHours/WorkingHours';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
|
@ -23,21 +27,21 @@ const WEEK_IN_SECONDS = 60 * 60 * 24 * 7;
|
|||
|
||||
export const UserItem = ({ pk, shiftColor, shiftStart, shiftEnd }: UserItemProps) => {
|
||||
const { userStore } = useStore();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userStore.items[pk]) {
|
||||
userStore.fetchItemById({ userPk: pk, skipIfAlreadyPending: true });
|
||||
userStore.fetchItemById({ userPk: pk, skipIfAlreadyPending: true, skipErrorHandling: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const name = userStore.items[pk]?.username;
|
||||
const desc = userStore.items[pk]?.timezone;
|
||||
const workingHours = userStore.items[pk]?.working_hours;
|
||||
const timezone = userStore.items[pk]?.timezone;
|
||||
const workingHours = userStore.items[pk]?.working_hours;
|
||||
const duration = dayjs(shiftEnd).diff(dayjs(shiftStart), 'seconds');
|
||||
|
||||
return (
|
||||
<div className={cx('user-item')} style={{ backgroundColor: shiftColor, width: '100%' }}>
|
||||
const slotContent = name ? (
|
||||
<>
|
||||
{duration <= WEEK_IN_SECONDS && (
|
||||
<WorkingHours
|
||||
timezone={timezone}
|
||||
|
|
@ -48,8 +52,26 @@ export const UserItem = ({ pk, shiftColor, shiftStart, shiftEnd }: UserItemProps
|
|||
/>
|
||||
)}
|
||||
<div className={cx('user-title')}>
|
||||
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
|
||||
<Text strong>{name}</Text> <Text className={styles.gray}>({timezone})</Text>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={cx('user-title')}>
|
||||
<NonExistentUserName justify="flex-start" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('user-item')} style={{ backgroundColor: shiftColor, width: '100%' }}>
|
||||
{slotContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
gray: css`
|
||||
color: ${COLORS.ALWAYS_GREY};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ const ScheduleCommonFields = () => {
|
|||
render={({ field }) => (
|
||||
<Field label="Assign to team" invalid={!!errors.team} error={errors.team?.message}>
|
||||
<GSelect<GrafanaTeam>
|
||||
showSearch
|
||||
items={grafanaTeamStore.items}
|
||||
fetchItemsFn={grafanaTeamStore.updateItems}
|
||||
fetchItemFn={grafanaTeamStore.fetchItemById}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,8 @@
|
|||
z-index: 1;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import dayjs from 'dayjs';
|
|||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import NonExistentUserName from 'components/NonExistentUserName/NonExistentUserName';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WorkingHours } from 'components/WorkingHours/WorkingHours';
|
||||
|
|
@ -59,7 +61,7 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
|
||||
const currentMoment = useMemo(() => dayjs(), []);
|
||||
|
||||
const renderEvent = (event): React.ReactElement | React.ReactElement[] => {
|
||||
const renderEvent = (event: Event): React.ReactElement | React.ReactElement[] => {
|
||||
if (event.shiftSwapId) {
|
||||
return <ShiftSwapEvent currentMoment={currentMoment} event={event} />;
|
||||
}
|
||||
|
|
@ -74,12 +76,31 @@ export const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
|
|||
|
||||
if (event.is_empty) {
|
||||
return (
|
||||
<div
|
||||
className={cx('root')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<RenderConditionally
|
||||
shouldRender={event.missing_users.length > 0}
|
||||
backupChildren={
|
||||
<div
|
||||
className={cx('root')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{event.missing_users.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className={cx('root')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
<div className={cx('title')}>
|
||||
<NonExistentUserName userName={name} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RenderConditionally>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ export const ServiceNowTokenSection: React.FC<ServiceNowTokenSectionProps> = obs
|
|||
|
||||
<Text>
|
||||
Copy and paste the following script to ServiceNow to allow communication between ServiceNow and OnCall{' '}
|
||||
<a href={`${DOCS_ROOT}/integrations/servicenow/#create-integration`} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={`${DOCS_ROOT}/integrations/servicenow/#generate-business-rule-script`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">Read more</Text>
|
||||
</a>
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Alert, Button, HorizontalGroup, InlineField, Input, VerticalGroup } from '@grafana/ui';
|
||||
import { Alert, Button, HorizontalGroup, InlineField, Input, VerticalGroup, useTheme2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
|
|
@ -11,7 +11,6 @@ import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
|||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
import styles from 'containers/UserSettings/parts/UserSettingsParts.module.css';
|
||||
|
||||
|
|
@ -26,6 +25,7 @@ export const PhoneConnector = observer((props: PhoneConnectorProps) => {
|
|||
const { id, onTabChange } = props;
|
||||
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
const { userStore } = store;
|
||||
|
||||
const storeUser = userStore.items[id];
|
||||
|
|
@ -134,8 +134,8 @@ export const PhoneConnector = observer((props: PhoneConnectorProps) => {
|
|||
<VerticalGroup spacing="xs">
|
||||
<div className={cx('tag-container')}>
|
||||
<Tag
|
||||
color={getVar('--tag-secondary-transparent')}
|
||||
border={getVar('--border-weak')}
|
||||
color={'rgba(204, 204, 220, 0.04)'}
|
||||
border={theme.colors.border.weak}
|
||||
className={cx('tag', 'tag-left')}
|
||||
>
|
||||
<Text type="primary" size="small">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { runInAction, makeAutoObservable } from 'mobx';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { convertFiltersToBackendFormat } from 'models/filters/filters.helpers';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { makeRequest } from 'network/network';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
|
|
@ -8,7 +9,7 @@ import { onCallApi } from 'network/oncall-api/http-client';
|
|||
import { RootStore } from 'state/rootStore';
|
||||
import { SelectOption } from 'state/types';
|
||||
import { LocationHelper } from 'utils/LocationHelper';
|
||||
import { GENERIC_ERROR } from 'utils/consts';
|
||||
import { GENERIC_ERROR, PAGE } from 'utils/consts';
|
||||
import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators';
|
||||
|
||||
import { AlertGroupHelper } from './alertgroup.helpers';
|
||||
|
|
@ -53,12 +54,18 @@ export class AlertGroupStore {
|
|||
);
|
||||
const timestamp = new Date().getTime();
|
||||
this.latestFetchAlertGroupsTimestamp = timestamp;
|
||||
|
||||
const incidentFilters = convertFiltersToBackendFormat(
|
||||
this.incidentFilters,
|
||||
this.rootStore.filtersStore.options[PAGE.Incidents]
|
||||
);
|
||||
|
||||
const {
|
||||
data: { results, next: nextRaw, previous: previousRaw, page_size },
|
||||
} = await onCallApi().GET('/alertgroups/', {
|
||||
params: {
|
||||
query: {
|
||||
...this.incidentFilters,
|
||||
...incidentFilters,
|
||||
search,
|
||||
perpage: this.alertsSearchResult?.page_size,
|
||||
cursor: this.incidentsCursor,
|
||||
|
|
@ -201,8 +208,13 @@ export class AlertGroupStore {
|
|||
}
|
||||
|
||||
async fetchStats(status: IncidentStatus) {
|
||||
const incidentFilters = convertFiltersToBackendFormat(
|
||||
this.incidentFilters,
|
||||
this.rootStore.filtersStore.options[PAGE.Incidents]
|
||||
);
|
||||
|
||||
const { data } = await onCallApi().GET('/alertgroups/stats/', {
|
||||
params: { query: { ...this.incidentFilters, status: [status] } },
|
||||
params: { query: { ...incidentFilters, status: [status] } },
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { convertRelativeToAbsoluteDate } from 'utils/datetime';
|
||||
|
||||
import { FilterOption, FiltersValues } from './filters.types';
|
||||
|
||||
export const getApiPathByPage = (page: string) => {
|
||||
return (
|
||||
{
|
||||
|
|
@ -7,3 +11,13 @@ export const getApiPathByPage = (page: string) => {
|
|||
}[page] || page
|
||||
);
|
||||
};
|
||||
|
||||
export const convertFiltersToBackendFormat = (filters: FiltersValues, filterOptions: FilterOption[]) => {
|
||||
const newFilters = { ...filters };
|
||||
filterOptions.forEach((filterOption) => {
|
||||
if (filterOption.type === 'daterange' && newFilters[filterOption.name]) {
|
||||
newFilters[filterOption.name] = convertRelativeToAbsoluteDate(filters[filterOption.name]);
|
||||
}
|
||||
});
|
||||
return newFilters;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ export class ScheduleStore extends BaseStore {
|
|||
...this.events[scheduleId],
|
||||
[type]: {
|
||||
...this.events[scheduleId]?.[type],
|
||||
[fromString]: layers ? layers : shifts,
|
||||
[fromString]: layers || shifts,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export interface Event {
|
|||
end: string;
|
||||
is_empty: boolean;
|
||||
is_gap: boolean;
|
||||
missing_users: Array<{ display_name: ApiSchemas['User']['username']; pk: ApiSchemas['User']['pk'] }>;
|
||||
missing_users: Array<ApiSchemas['User']['username']>;
|
||||
priority_level: number;
|
||||
shift: Pick<Shift, 'name' | 'type'> & { pk: string };
|
||||
source: string;
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
.navbar-star-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.header-topnavbar {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.navbar-heading {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--gray-9);
|
||||
width: initial;
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.navbar-heading-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
margin-left: -50px;
|
||||
}
|
||||
|
||||
.irm-icon {
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #ffb375;
|
||||
color: #ffb375;
|
||||
}
|
||||
|
||||
.banners {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:empty {
|
||||
padding-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container,
|
||||
.page-header__img {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
76
grafana-plugin/src/navbar/Header/Header.styles.ts
Normal file
76
grafana-plugin/src/navbar/Header/Header.styles.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getHeaderStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
navbarStarIcon: css`
|
||||
margin-right: 4px;
|
||||
`,
|
||||
|
||||
headerTopNavbar: css`
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 32px;
|
||||
`,
|
||||
|
||||
navbarHeading: css`
|
||||
padding: 4px;
|
||||
border: 1px solid ${theme.colors.secondary.border};
|
||||
width: initial;
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
|
||||
navbarLink: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
`,
|
||||
|
||||
navbarLeft: css`
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
|
||||
navbarHeadingContainer: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
margin-left: -50px;
|
||||
`,
|
||||
|
||||
irmIcon: css`
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #ffb375;
|
||||
color: #ffb375;
|
||||
`,
|
||||
|
||||
banners: css`
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:empty {
|
||||
padding-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`,
|
||||
|
||||
logoContainer: css`
|
||||
height: 32px;
|
||||
margin-top: 2px;
|
||||
`,
|
||||
|
||||
pageHeaderImage: css`
|
||||
height: 32px;
|
||||
`,
|
||||
|
||||
pageHeaderTitle: css`
|
||||
margin-bottom: 8px;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card, HorizontalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { cx } from '@emotion/css';
|
||||
import { Card, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import gitHubStarSVG from 'assets/img/github_star.svg';
|
||||
|
|
@ -11,22 +11,21 @@ import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { APP_SUBTITLE } from 'utils/consts';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
import { getHeaderStyles } from './Header.styles';
|
||||
|
||||
export const Header = observer(() => {
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getHeaderStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('page-header__inner', { 'header-topnavbar': isTopNavbar() })}>
|
||||
<div className={cx('navbar-left')}>
|
||||
<span className={cx('page-header__logo', 'logo-container')}>
|
||||
<img className={cx('page-header__img')} src={logo} alt="Grafana OnCall" />
|
||||
<div>
|
||||
<div className={cx('page-header__inner', { [styles.headerTopNavbar]: isTopNavbar() })}>
|
||||
<div className={cx(styles.navbarLeft)}>
|
||||
<span className={cx('page-header__logo', styles.logoContainer)}>
|
||||
<img className={cx(styles.pageHeaderImage)} src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
<div className="page-header__info-block">{renderHeading()}</div>
|
||||
<div className={cx('page-header__info-block')}>{renderHeading()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -38,18 +37,18 @@ export const Header = observer(() => {
|
|||
if (store.isOpenSource) {
|
||||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<div className={cx('navbar-heading-container')}>
|
||||
<h1 className={cx(styles.pageHeaderTitle)}>Grafana OnCall</h1>
|
||||
<div className={cx(styles.navbarHeadingContainer)}>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
|
||||
<Card heading={undefined} className={cx('navbar-heading')}>
|
||||
<Card heading={undefined} className={cx(styles.navbarHeading)}>
|
||||
<a
|
||||
href="https://github.com/grafana/oncall"
|
||||
className={cx('navbar-link')}
|
||||
className={cx(styles.navbarLink)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={gitHubStarSVG} className={cx('navbar-star-icon')} alt="" /> Star us on GitHub
|
||||
<img src={gitHubStarSVG} className={cx(styles.navbarStarIcon)} alt="" /> Star us on GitHub
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -60,7 +59,7 @@ export const Header = observer(() => {
|
|||
return (
|
||||
<>
|
||||
<HorizontalGroup>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<h1 className={cx(styles.pageHeaderTitle)}>Grafana OnCall</h1>
|
||||
</HorizontalGroup>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
</>
|
||||
|
|
@ -69,8 +68,9 @@ export const Header = observer(() => {
|
|||
});
|
||||
|
||||
const Banners: React.FC = () => {
|
||||
const styles = useStyles2(getHeaderStyles);
|
||||
return (
|
||||
<div className={cx('banners')}>
|
||||
<div className={cx(styles.banners)}>
|
||||
<Alerts />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import cn from 'classnames/bind';
|
|||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { Tag } from 'components/Tag/Tag';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { TextEllipsisTooltip } from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
|
|
@ -13,7 +12,6 @@ import { IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
|||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
|
||||
import { move } from 'state/helpers';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { TEXT_ELLIPSIS_CLASS } from 'utils/consts';
|
||||
|
||||
|
|
@ -21,45 +19,6 @@ import styles from './Incident.module.scss';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export function getIncidentStatusTag(alert: ApiSchemas['AlertGroup']) {
|
||||
switch (alert.status) {
|
||||
case IncidentStatus.Firing:
|
||||
return (
|
||||
<Tag color={getVar('--tag-danger')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Firing
|
||||
</Text>
|
||||
</Tag>
|
||||
);
|
||||
case IncidentStatus.Acknowledged:
|
||||
return (
|
||||
<Tag color={getVar('--tag-warning')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Acknowledged
|
||||
</Text>
|
||||
</Tag>
|
||||
);
|
||||
case IncidentStatus.Resolved:
|
||||
return (
|
||||
<Tag color={getVar('--tag-primary')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Resolved
|
||||
</Text>
|
||||
</Tag>
|
||||
);
|
||||
case IncidentStatus.Silenced:
|
||||
return (
|
||||
<Tag color={getVar('--tag-secondary')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Silenced
|
||||
</Text>
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRelatedUsers(incident: ApiSchemas['AlertGroup'], isFull = false) {
|
||||
const { related_users } = incident;
|
||||
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
team: [],
|
||||
status: [IncidentStatus.Firing, IncidentStatus.Acknowledged],
|
||||
mine: false,
|
||||
started_at: `${defaultStart.format('YYYY-MM-DDTHH:mm:ss')}/${defaultEnd.format('YYYY-MM-DDTHH:mm:ss')}`,
|
||||
started_at: `${defaultStart.format('YYYY-MM-DDTHH:mm:ss')}_${defaultEnd.format('YYYY-MM-DDTHH:mm:ss')}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
.incident__tag {
|
||||
padding: 5px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.incident__icon {
|
||||
margin-right: -4px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.incident__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.incident__option-item {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
min-width: 84px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-9);
|
||||
}
|
||||
|
||||
&--acknowledge {
|
||||
color: var(--tag-warning);
|
||||
}
|
||||
|
||||
&--firing {
|
||||
color: var(--error-text-color);
|
||||
}
|
||||
|
||||
&--resolve {
|
||||
color: var(--success-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.incident__option-span > div {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getIncidentDropdownStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
incidentTag: css`
|
||||
padding: 5px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
`,
|
||||
|
||||
incidentIcon: css`
|
||||
margin-right: -4px;
|
||||
margin-left: 2px;
|
||||
`,
|
||||
|
||||
incidentOptions: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
|
||||
incidentOptionItem: css`
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
min-width: 84px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
color: ${theme.colors.text.primary};
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.action.hover};
|
||||
}
|
||||
`,
|
||||
|
||||
incidentOptionEl: css`
|
||||
> div {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,33 +1,31 @@
|
|||
import React, { FC, SyntheticEvent, useRef, useState } from 'react';
|
||||
|
||||
import { Icon, LoadingPlaceholder } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { cx } from '@emotion/css';
|
||||
import { Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { Tag } from 'components/Tag/Tag';
|
||||
import { Tag, TagColor } from 'components/Tag/Tag';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import styles from 'pages/incidents/parts/IncidentDropdown.module.scss';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import { getIncidentDropdownStyles } from './IncidentDropdown.styles';
|
||||
import { SilenceSelect } from './SilenceSelect';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const getIncidentTagColor = (alert: ApiSchemas['AlertGroup']) => {
|
||||
if (alert.status === IncidentStatus.Resolved) {
|
||||
return getVar('--tag-primary');
|
||||
return TagColor.SUCCESS;
|
||||
}
|
||||
if (alert.status === IncidentStatus.Firing) {
|
||||
return getVar('--tag-danger');
|
||||
return TagColor.ERROR;
|
||||
}
|
||||
if (alert.status === IncidentStatus.Acknowledged) {
|
||||
return getVar('--tag-warning');
|
||||
return TagColor.WARNING;
|
||||
}
|
||||
return getVar('--tag-secondary');
|
||||
return TagColor.SECONDARY;
|
||||
};
|
||||
|
||||
function IncidentStatusTag({
|
||||
|
|
@ -37,12 +35,13 @@ function IncidentStatusTag({
|
|||
alert: ApiSchemas['AlertGroup'];
|
||||
openMenu: React.MouseEventHandler<HTMLElement>;
|
||||
}) {
|
||||
const styles = useStyles2(getIncidentDropdownStyles);
|
||||
const forwardedRef = useRef<HTMLSpanElement>();
|
||||
|
||||
return (
|
||||
<Tag
|
||||
forwardedRef={forwardedRef}
|
||||
className={cx('incident__tag')}
|
||||
className={cx(styles.incidentTag)}
|
||||
color={getIncidentTagColor(alert)}
|
||||
onClick={() => {
|
||||
const boundingRect = forwardedRef.current.getBoundingClientRect();
|
||||
|
|
@ -50,10 +49,8 @@ function IncidentStatusTag({
|
|||
openMenu({ pageX: boundingRect.left + LEFT_MARGIN, pageY: boundingRect.top + boundingRect.height } as any);
|
||||
}}
|
||||
>
|
||||
<Text strong size="small">
|
||||
{IncidentStatus[alert.status]}
|
||||
</Text>
|
||||
<Icon className={cx('incident__icon')} name="angle-down" size="sm" />
|
||||
<Text size="small">{IncidentStatus[alert.status]}</Text>
|
||||
<Icon className={cx(styles.incidentIcon)} name="angle-down" size="sm" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,6 +68,9 @@ export const IncidentDropdown: FC<{
|
|||
const [currentLoadingAction, setCurrentActionLoading] = useState<IncidentStatus>(undefined);
|
||||
const [forcedOpenAction, setForcedOpenAction] = useState<string>(undefined);
|
||||
|
||||
const styles = useStyles2(getIncidentDropdownStyles);
|
||||
const utilStyles = useStyles2(getUtilStyles);
|
||||
|
||||
const onClickFn = async (
|
||||
ev: React.SyntheticEvent<HTMLDivElement>,
|
||||
actionName: string,
|
||||
|
|
@ -96,15 +96,15 @@ export const IncidentDropdown: FC<{
|
|||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Resolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--firing')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Resolve, onUnresolve, IncidentStatus.Firing)}
|
||||
>
|
||||
Firing{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -123,15 +123,15 @@ export const IncidentDropdown: FC<{
|
|||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Acknowledge}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--unacknowledge')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onUnacknowledge, IncidentStatus.Firing)}
|
||||
>
|
||||
Unacknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -139,12 +139,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Acknowledge, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -163,15 +163,15 @@ export const IncidentDropdown: FC<{
|
|||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.unResolve}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident__options', { 'u-disabled': isLoading })}>
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -179,19 +179,19 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.unResolve, onResolve, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
|
||||
<div className={cx('incident__option-item')}>
|
||||
<div className={cx(styles.incidentOptionItem)}>
|
||||
<SilenceSelect
|
||||
placeholder={
|
||||
currentLoadingAction === IncidentStatus.Silenced && isLoading ? 'Loading...' : 'Silence for'
|
||||
|
|
@ -222,15 +222,15 @@ export const IncidentDropdown: FC<{
|
|||
<WithContextMenu
|
||||
forceIsOpen={forcedOpenAction === AlertAction.Silence}
|
||||
renderMenuItems={() => (
|
||||
<div className={cx('incident_options', { 'u-disabled': isLoading })}>
|
||||
<div className={cx(styles.incidentOptions, { [utilStyles.disabled]: isLoading })}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onUnsilence, IncidentStatus.Firing)}
|
||||
>
|
||||
Unsilence{' '}
|
||||
{currentLoadingAction === IncidentStatus.Firing && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -238,12 +238,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--acknowledge')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Acknowledged)}
|
||||
>
|
||||
Acknowledge{' '}
|
||||
{currentLoadingAction === IncidentStatus.Acknowledged && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -251,12 +251,12 @@ export const IncidentDropdown: FC<{
|
|||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<div
|
||||
className={cx('incident__option-item', 'incident__option-item--resolve')}
|
||||
className={cx(styles.incidentOptionItem)}
|
||||
onClick={(e) => onClickFn(e, AlertAction.Silence, onAcknowledge, IncidentStatus.Resolved)}
|
||||
>
|
||||
Resolve{' '}
|
||||
{currentLoadingAction === IncidentStatus.Resolved && isLoading && (
|
||||
<span className={cx('incident__option-span')}>
|
||||
<span className={cx(styles.incidentOptionEl)}>
|
||||
<LoadingPlaceholder text="" />
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { Tab, TabsBar } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -8,8 +9,9 @@ import { ChatOpsPage } from 'pages/settings/tabs/ChatOps/ChatOps';
|
|||
import { MainSettings } from 'pages/settings/tabs/MainSettings/MainSettings';
|
||||
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { LocationHelper } from 'utils/LocationHelper';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
import { SettingsPageTab } from './SettingsPage.types';
|
||||
|
|
@ -21,18 +23,22 @@ import styles from './SettingsPage.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface SettingsPageProps {
|
||||
store: RootBaseStore;
|
||||
}
|
||||
interface SettingsPageProps extends AppRootProps, WithStoreProps {}
|
||||
interface SettingsPageState {
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
@observer
|
||||
class Settings extends React.Component<SettingsPageProps, SettingsPageState> {
|
||||
state: SettingsPageState = {
|
||||
activeTab: SettingsPageTab.MainSettings.key, // should read from route instead
|
||||
};
|
||||
constructor(props: SettingsPageProps) {
|
||||
super(props);
|
||||
|
||||
const tab = LocationHelper.getQueryParam('tab');
|
||||
|
||||
this.state = {
|
||||
activeTab: tab || SettingsPageTab.MainSettings.key,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className={cx('root')}>{this.renderContent()}</div>;
|
||||
|
|
@ -44,6 +50,7 @@ class Settings extends React.Component<SettingsPageProps, SettingsPageState> {
|
|||
|
||||
const onTabChange = (tab: string) => {
|
||||
this.setState({ activeTab: tab });
|
||||
LocationHelper.update({ tab }, 'partial');
|
||||
};
|
||||
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export class _ChatOpsPage extends React.Component<ChatOpsProps, ChatOpsState> {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { query } = this.props; // eslint-disable-line
|
||||
const tab = LocationHelper.getQueryParam('chatOpsTab');
|
||||
|
||||
this.handleChatopsTabChange(query?.tab || ChatOpsTab.Slack);
|
||||
this.handleChatopsTabChange(tab || ChatOpsTab.Slack);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
|
@ -94,7 +94,7 @@ export class _ChatOpsPage extends React.Component<ChatOpsProps, ChatOpsState> {
|
|||
|
||||
handleChatopsTabChange(tab: ChatOpsTab) {
|
||||
this.setState({ activeTab: tab });
|
||||
LocationHelper.update({ tab: tab }, 'partial');
|
||||
LocationHelper.update({ chatOpsTab: tab }, 'partial');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
export const getUtilStyles = (_theme: GrafanaTheme2) => {
|
||||
export const getUtilStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
width100: css`
|
||||
width: 100%;
|
||||
`,
|
||||
|
||||
disabled: css`
|
||||
opacity: 0.5;
|
||||
`,
|
||||
|
||||
thinLineBreak: css`
|
||||
width: 100%;
|
||||
border-top: 1px solid ${COLORS.ALWAYS_GREY};
|
||||
border-top: 1px solid ${theme.colors.secondary.main};
|
||||
margin-top: 8px;
|
||||
opacity: 15%;
|
||||
`,
|
||||
|
|
@ -24,6 +29,34 @@ export const getUtilStyles = (_theme: GrafanaTheme2) => {
|
|||
};
|
||||
};
|
||||
|
||||
export function getLabelBackgroundTextColorObject(
|
||||
color: string,
|
||||
theme: GrafanaTheme2
|
||||
): { bgColor: string; textColor: string; sourceColor: string } {
|
||||
let sourceColor = theme.visualization.getColorByName(color);
|
||||
let bgColor = '';
|
||||
let textColor = '';
|
||||
|
||||
if (theme.isDark) {
|
||||
bgColor = tinycolor(sourceColor).setAlpha(0.25).toString();
|
||||
textColor = tinycolor(sourceColor).lighten(15).toString();
|
||||
} else {
|
||||
bgColor = tinycolor(sourceColor).setAlpha(0.25).toString();
|
||||
textColor = tinycolor(sourceColor).darken(20).toString();
|
||||
}
|
||||
|
||||
return { bgColor, textColor, sourceColor };
|
||||
}
|
||||
|
||||
export function getLabelCss(color: string, theme: GrafanaTheme2) {
|
||||
const { bgColor, textColor, sourceColor } = getLabelBackgroundTextColorObject(color, theme);
|
||||
return css`
|
||||
border: 1px solid ${sourceColor};
|
||||
background-color: ${bgColor};
|
||||
color: ${textColor};
|
||||
`;
|
||||
}
|
||||
|
||||
export const bem = (...args: string[]) =>
|
||||
args.reduce((out, x, i) => {
|
||||
out += x;
|
||||
|
|
@ -39,5 +72,6 @@ export const bem = (...args: string[]) =>
|
|||
export enum COLORS {
|
||||
ALWAYS_GREY = '#ccccdc',
|
||||
GRAY_8 = '#595959',
|
||||
GRAY_9 = '#434343',
|
||||
GREEN_5 = '#6ccf8e',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ export const waitForElement = (selector: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const getVar = (cssVar: string): string => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar);
|
||||
};
|
||||
|
||||
export const getCoords = (elem) => {
|
||||
// crossbrowser version
|
||||
const box = elem.getBoundingClientRect();
|
||||
|
|
|
|||
48
grafana-plugin/src/utils/datetime.test.ts
Normal file
48
grafana-plugin/src/utils/datetime.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
import { convertRelativeToAbsoluteDate, getValueForDateRangeFilterType } from './datetime';
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss';
|
||||
|
||||
describe('convertRelativeToAbsoluteDate', () => {
|
||||
it(`should convert relative date to absolute dates pair separated by an underscore`, () => {
|
||||
const result = convertRelativeToAbsoluteDate('now-24h_now');
|
||||
|
||||
const now = moment().utc();
|
||||
const nowString = now.format(DATE_FORMAT);
|
||||
const dayBefore = now.subtract('1', 'day');
|
||||
const dayBeforeString = dayBefore.format(DATE_FORMAT);
|
||||
|
||||
expect(result).toBe(`${dayBeforeString}_${nowString}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValueForDateRangeFilterType', () => {
|
||||
it(`should convert relative date range string to a suitable format for TimeRangeInput component`, () => {
|
||||
const input = 'now-2d_now';
|
||||
const [from, to] = input.split('_');
|
||||
const result = getValueForDateRangeFilterType(input);
|
||||
|
||||
const now = moment();
|
||||
const twoDaysBefore = now.subtract('2', 'day');
|
||||
|
||||
expect(result.from.diff(twoDaysBefore, 'seconds') < 1).toBe(true);
|
||||
expect(result.raw.from).toBe(from);
|
||||
expect(result.from.diff(twoDaysBefore, 'seconds') < 1).toBe(true);
|
||||
expect(result.raw.to).toBe(to);
|
||||
});
|
||||
|
||||
it(`should convert absolute date range string to a suitable format for TimeRangeInput component`, () => {
|
||||
const input = '2024-03-31T23:00:00_2024-04-15T22:59:59';
|
||||
const [from, to] = input.split('_');
|
||||
const result = getValueForDateRangeFilterType(input);
|
||||
|
||||
const fromMoment = moment(from + 'Z');
|
||||
const toMoment = moment(to + 'Z');
|
||||
|
||||
expect(result.from.diff(fromMoment, 'seconds') < 1).toBe(true);
|
||||
expect(result.raw.from).toBe(from);
|
||||
expect(result.from.diff(toMoment, 'seconds') < 1).toBe(true);
|
||||
expect(result.raw.to).toBe(to);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,68 +1,70 @@
|
|||
import { TimeOption, TimeRange, rangeUtil } from '@grafana/data';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
// Valid mapping accepted by @grafana/ui and @grafana/data packages
|
||||
export const quickOptions = [
|
||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
|
||||
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
|
||||
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes' },
|
||||
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
|
||||
{ from: 'now-3h', to: 'now', display: 'Last 3 hours' },
|
||||
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
|
||||
{ from: 'now-12h', to: 'now', display: 'Last 12 hours' },
|
||||
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
|
||||
{ from: 'now-2d', to: 'now', display: 'Last 2 days' },
|
||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
|
||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days' },
|
||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days' },
|
||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months' },
|
||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year' },
|
||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years' },
|
||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years' },
|
||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' },
|
||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday' },
|
||||
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' },
|
||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
|
||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
|
||||
{ from: 'now-1Q/fQ', to: 'now-1Q/fQ', display: 'Previous fiscal quarter' },
|
||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
|
||||
{ from: 'now-1y/fy', to: 'now-1y/fy', display: 'Previous fiscal year' },
|
||||
{ from: 'now/d', to: 'now/d', display: 'Today' },
|
||||
{ from: 'now/d', to: 'now', display: 'Today so far' },
|
||||
{ from: 'now/w', to: 'now/w', display: 'This week' },
|
||||
{ from: 'now/w', to: 'now', display: 'This week so far' },
|
||||
{ from: 'now/M', to: 'now/M', display: 'This month' },
|
||||
{ from: 'now/M', to: 'now', display: 'This month so far' },
|
||||
{ from: 'now/y', to: 'now/y', display: 'This year' },
|
||||
{ from: 'now/y', to: 'now', display: 'This year so far' },
|
||||
{ from: 'now/fQ', to: 'now', display: 'This fiscal quarter so far' },
|
||||
{ from: 'now/fQ', to: 'now/fQ', display: 'This fiscal quarter' },
|
||||
{ from: 'now/fy', to: 'now', display: 'This fiscal year so far' },
|
||||
{ from: 'now/fy', to: 'now/fy', display: 'This fiscal year' },
|
||||
];
|
||||
export const DATE_RANGE_DELIMITER = '_';
|
||||
|
||||
export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): TimeRange => {
|
||||
return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone);
|
||||
};
|
||||
|
||||
export function convertRelativeToAbsoluteDate(dateRangeString: string) {
|
||||
const [from, to] = dateRangeString?.split('/') || [];
|
||||
const isValidMapping = quickOptions.find((option) => option.from === from && option.to === to);
|
||||
|
||||
if (isValidMapping) {
|
||||
const { from: startDate, to: endDate } = mapOptionToTimeRange({ from, to } as TimeOption);
|
||||
|
||||
// Return in the format used by on call filters
|
||||
return `${startDate.format('YYYY-MM-DDTHH:mm:ss')}/${endDate.format('YYYY-MM-DDTHH:mm:ss')}`;
|
||||
if (!dateRangeString) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const quickOption = quickOptions.find((option) => option.display.toLowerCase() === dateRangeString.toLowerCase());
|
||||
const [from, to] = dateRangeString?.split(DATE_RANGE_DELIMITER) || [];
|
||||
if (rangeUtil.isRelativeTimeRange({ from, to })) {
|
||||
const { from: startDate, to: endDate } = rangeUtil.convertRawToRange({ from, to });
|
||||
|
||||
if (quickOption) {
|
||||
const { from: startDate, to: endDate } = mapOptionToTimeRange(quickOption as TimeOption);
|
||||
|
||||
return `${startDate.format('YYYY-MM-DDTHH:mm:ss')}/${endDate.format('YYYY-MM-DDTHH:mm:ss')}`;
|
||||
return `${startDate.utc().format('YYYY-MM-DDTHH:mm:ss')}${DATE_RANGE_DELIMITER}${endDate
|
||||
.utc()
|
||||
.format('YYYY-MM-DDTHH:mm:ss')}`;
|
||||
}
|
||||
|
||||
return dateRangeString;
|
||||
}
|
||||
|
||||
export const convertTimerangeToFilterValue = (timeRange: TimeRange) => {
|
||||
const isRelative = rangeUtil.isRelativeTimeRange(timeRange.raw);
|
||||
|
||||
if (isRelative) {
|
||||
return timeRange.raw.from + DATE_RANGE_DELIMITER + timeRange.raw.to;
|
||||
} else if (timeRange.from.isValid() && timeRange.to.isValid()) {
|
||||
return (
|
||||
timeRange.from.utc().format('YYYY-MM-DDTHH:mm:ss') +
|
||||
DATE_RANGE_DELIMITER +
|
||||
timeRange.to.utc().format('YYYY-MM-DDTHH:mm:ss')
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getValueForDateRangeFilterType = (rawInput: string) => {
|
||||
let value = { from: undefined, to: undefined, raw: { from: '', to: '' } };
|
||||
if (rawInput) {
|
||||
const [fromString, toString] = rawInput.split(DATE_RANGE_DELIMITER);
|
||||
const isRelative = rangeUtil.isRelativeTimeRange({ from: fromString, to: toString });
|
||||
|
||||
const raw = {
|
||||
from: fromString,
|
||||
to: toString,
|
||||
};
|
||||
|
||||
if (isRelative) {
|
||||
const absolute = convertRelativeToAbsoluteDate(rawInput);
|
||||
const [absoluteFrom, absoluteTo] = absolute.split(DATE_RANGE_DELIMITER);
|
||||
value = {
|
||||
from: moment(absoluteFrom + 'Z'),
|
||||
to: moment(absoluteTo + 'Z'),
|
||||
raw,
|
||||
};
|
||||
} else {
|
||||
value = {
|
||||
from: moment(fromString + 'Z'),
|
||||
to: moment(toString + 'Z'),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13835,7 +13835,7 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
|||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tinycolor2@1.6.0:
|
||||
tinycolor2@1.6.0, tinycolor2@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
|
||||
|
|
|
|||
4
tools/pagerduty-migrator/requirements.in
Normal file
4
tools/pagerduty-migrator/requirements.in
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
requests==2.31.0
|
||||
pdpyras==4.5.0
|
||||
pytest==7.1.2
|
||||
pytest-env==0.6.2
|
||||
|
|
@ -1,4 +1,40 @@
|
|||
requests==2.31.0
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements.in
|
||||
#
|
||||
attrs==23.2.0
|
||||
# via pytest
|
||||
certifi==2024.2.2
|
||||
# via requests
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
idna==3.7
|
||||
# via requests
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
packaging==23.2
|
||||
# via pytest
|
||||
pdpyras==4.5.0
|
||||
# via -r requirements.in
|
||||
pluggy==1.4.0
|
||||
# via pytest
|
||||
py==1.11.0
|
||||
# via pytest
|
||||
pytest==7.1.2
|
||||
pytest-env==0.6.2
|
||||
# via
|
||||
# -r requirements.in
|
||||
# pytest-env
|
||||
pytest-env==0.6.2
|
||||
# via -r requirements.in
|
||||
requests==2.31.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# pdpyras
|
||||
tomli==2.0.1
|
||||
# via pytest
|
||||
urllib3==2.2.1
|
||||
# via
|
||||
# pdpyras
|
||||
# requests
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue