Merge pull request #4320 from grafana/dev

v1.4.5
This commit is contained in:
Michael Derynck 2024-05-08 08:21:54 -06:00 committed by GitHub
commit f21589c216
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 1829 additions and 780 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -79,6 +79,7 @@ const getStyles = () => {
return {
root: css`
position: relative;
z-index: 1;
`,
avatar: css`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -522,7 +522,7 @@ export class ScheduleStore extends BaseStore {
...this.events[scheduleId],
[type]: {
...this.events[scheduleId]?.[type],
[fromString]: layers ? layers : shifts,
[fromString]: layers || shifts,
},
},
};

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
requests==2.31.0
pdpyras==4.5.0
pytest==7.1.2
pytest-env==0.6.2

View file

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