This commit is contained in:
Joey Orlando 2023-11-14 15:17:29 -05:00 committed by GitHub
commit 4254f82ead
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 673 additions and 4542 deletions

View file

@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## v1.3.58 (2023-11-14)
### Added
- Added user timezone field to the users public API response ([#3311](https://github.com/grafana/oncall/pull/3311))
- Allow filtering users by public primary key in internal API ([#3339](https://github.com/grafana/oncall/pull/3339))
### Changed
- Split Integrations table into Connections and Direct Paging tabs ([#3290](https://github.com/grafana/oncall/pull/3290))
### Fixed
- Fix issue where Slack user connection error message is sometimes shown despite successful connection by @joeyorlando ([#3327](https://github.com/grafana/oncall/pull/3327))
- Forward headers for Amazon SNS when organizations are moved @mderynck ([#3326](https://github.com/grafana/oncall/pull/3326))
- Fix styling when light theme is turned on via system preferences
by excluding dark theme css vars in this case ([#3336](https://github.com/grafana/oncall/pull/3336))
- Fix issue when acknowledge reminder works for deleted organizations @Ferril ([#3345](https://github.com/grafana/oncall/pull/3345))
- Fix generating QR code ([#3347](https://github.com/grafana/oncall/pull/3347))
## v1.3.57 (2023-11-10)
### Fixed

View file

@ -90,7 +90,7 @@ will be sent to users.
#### Heartbeat monitoring
An OnCall heartbeat acts as a healthcheck for alert group monitoring. You can configure you monitoring to regularly send alerts
to the heartbeat endpoint. If OnCall doen't receive one of these alerts, it will create an new alert group and escalate it
to the heartbeat endpoint. If OnCall doesn't receive one of these alerts, it will create an new alert group and escalate it
1. Go to Integration page and click **Three dots**
1. Select **Heartbeat Settings**

View file

@ -51,7 +51,7 @@ on this calendar will take precedence over the rotations calendar.
## Schedule quality report
The schedule view features a quality report that provides a score for your schedule based on rotations and overrides.
The schedule view features a quality report that provides a score for your schedule based on rotations, overrides and [shift swaps][shift-swaps].
It's calculated based on these key factors:
- Gaps (amount of time when no one is on-call)
@ -85,3 +85,8 @@ A perfectly balanced schedule is considered ideal, so reducing this number will
Export on-call schedules from Grafana OnCall to your preferred calendar app with a one-time secret iCal URL. The
schedule export allows you to view on-call shifts alongside the rest of your schedule.
{{% docs/reference %}}
[shift-swaps]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/on-call-schedules/shift-swaps"
[shift-swaps]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/on-call-schedules/shift-swaps"
{{% /docs/reference %}}

View file

@ -28,7 +28,8 @@ The above command returns JSON structured in the following way:
}
],
"username": "alex",
"role": "admin"
"role": "admin",
"timezone": "UTC"
}
```
@ -45,6 +46,7 @@ Use `{{API_URL}}/api/v1/users/current` to retrieve the current user.
| `slack` | Yes/org | List of user IDs from connected Slack. User linking key is e-mail. |
| `username` | Yes/org | User username |
| `role` | No | One of: `user`, `observer`, `admin`. |
| `timezone` | No | timezone of the user one of [time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |
# List Users
@ -73,7 +75,8 @@ The above command returns JSON structured in the following way:
}
],
"username": "alex",
"role": "admin"
"role": "admin",
"timezone": "UTC"
}
],
"current_page_number": 1,

View file

@ -26,13 +26,11 @@ def acknowledge_reminder_task(alert_group_pk: int, unacknowledge_process_id: str
if unacknowledge_process_id != alert_group.last_unique_unacknowledge_process_id:
return
organization = alert_group.channel.organization
# Get timeout values
acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[
alert_group.channel.organization.acknowledge_remind_timeout
]
unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[
alert_group.channel.organization.unacknowledge_timeout
]
acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[organization.acknowledge_remind_timeout]
unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[organization.unacknowledge_timeout]
# Don't proceed if the alert group is not in a state for acknowledgement reminder
acknowledge_reminder_required = (
@ -41,10 +39,18 @@ def acknowledge_reminder_task(alert_group_pk: int, unacknowledge_process_id: str
and alert_group.acknowledged_by == AlertGroup.USER
and acknowledge_reminder_timeout
)
if not acknowledge_reminder_required:
task_logger.info("AlertGroup is not in a state for acknowledgement reminder")
is_organization_deleted = organization.deleted_at is not None
log_info = (
f"acknowledge_reminder_timeout option: {acknowledge_reminder_timeout},"
f"organization ppk: {organization.public_primary_key},"
f"organization is deleted: {is_organization_deleted}"
)
if not acknowledge_reminder_required or is_organization_deleted:
task_logger.info(f"alert group {alert_group_pk} is not in a state for acknowledgement reminder. {log_info}")
return
task_logger.info(f"alert group {alert_group_pk} is in a state for acknowledgement reminder. {log_info}")
# unacknowledge_timeout_task uses acknowledged_by_confirmed to check if acknowledgement reminder has been confirmed
# by the user. Setting to None here to indicate that the user has not confirmed the acknowledgement reminder
alert_group.acknowledged_by_confirmed = None
@ -80,13 +86,11 @@ def unacknowledge_timeout_task(alert_group_pk: int, unacknowledge_process_id: st
if unacknowledge_process_id != alert_group.last_unique_unacknowledge_process_id:
return
organization = alert_group.channel.organization
# Get timeout values
acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[
alert_group.channel.organization.acknowledge_remind_timeout
]
unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[
alert_group.channel.organization.unacknowledge_timeout
]
acknowledge_reminder_timeout = Organization.ACKNOWLEDGE_REMIND_DELAY[organization.acknowledge_remind_timeout]
unacknowledge_timeout = Organization.UNACKNOWLEDGE_TIMEOUT_DELAY[organization.unacknowledge_timeout]
# Don't proceed if the alert group is not in a state for auto-unacknowledge
unacknowledge_required = (
@ -96,16 +100,28 @@ def unacknowledge_timeout_task(alert_group_pk: int, unacknowledge_process_id: st
and acknowledge_reminder_timeout
and unacknowledge_timeout
)
if not unacknowledge_required:
task_logger.info("AlertGroup is not in a state for unacknowledge")
is_organization_deleted = organization.deleted_at is not None
log_info = (
f"acknowledge_reminder_timeout option: {acknowledge_reminder_timeout},"
f"unacknowledge_timeout option: {unacknowledge_timeout},"
f"organization ppk: {organization.public_primary_key},"
f"organization is deleted: {is_organization_deleted}"
)
if not unacknowledge_required or is_organization_deleted:
task_logger.info(f"alert group {alert_group_pk} is not in a state for unacknowledge by timeout. {log_info}")
return
if alert_group.acknowledged_by_confirmed: # acknowledgement reminder was confirmed by the user
acknowledge_reminder_task.apply_async(
(alert_group_pk, unacknowledge_process_id), countdown=acknowledge_reminder_timeout - unacknowledge_timeout
)
task_logger.info(
f"Acknowledgement reminder was confirmed by user. Rescheduling acknowledge_reminder_task..."
f"alert group: {alert_group_pk}, {log_info}"
)
return
task_logger.info(f"alert group {alert_group_pk} is in a state for unacknowledge by timeout. {log_info}")
# If acknowledgement reminder wasn't confirmed by the user, unacknowledge the alert group and start escalation again
log_record = alert_group.log_records.create(
type=AlertGroupLogRecord.TYPE_AUTO_UN_ACK, author=alert_group.acknowledged_by_user

View file

@ -299,3 +299,43 @@ def test_unacknowledge_timeout_task_no_unacknowledge(
)
assert not alert_group.log_records.exists()
@patch.object(acknowledge_reminder_task, "apply_async")
@patch.object(unacknowledge_timeout_task, "apply_async")
@pytest.mark.django_db
def test_ack_reminder_skip_deleted_org(
mock_acknowledge_reminder_task,
mock_unacknowledge_timeout_task,
ack_reminder_test_setup,
):
organization, alert_group, user = ack_reminder_test_setup()
organization.deleted_at = timezone.now()
organization.save()
acknowledge_reminder_task(alert_group.pk, TASK_ID)
mock_unacknowledge_timeout_task.assert_not_called()
mock_acknowledge_reminder_task.assert_not_called()
assert not alert_group.log_records.exists()
@patch.object(acknowledge_reminder_task, "apply_async")
@patch.object(unacknowledge_timeout_task, "apply_async")
@pytest.mark.django_db
def test_unacknowledge_timeout_task_skip_deleted_org(
mock_acknowledge_reminder_task,
mock_unacknowledge_timeout_task,
ack_reminder_test_setup,
):
organization, alert_group, user = ack_reminder_test_setup()
organization.deleted_at = timezone.now()
organization.save()
unacknowledge_timeout_task(alert_group.pk, TASK_ID)
mock_unacknowledge_timeout_task.assert_not_called()
mock_acknowledge_reminder_task.assert_not_called()
assert not alert_group.log_records.exists()

View file

@ -33,6 +33,29 @@ def test_get_alert_receive_channel(alert_receive_channel_internal_api_setup, mak
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_get_alert_receive_channel_by_integration_ne(
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_alert_receive_channel
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA)
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING)
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
client = APIClient()
url = f"{reverse('api-internal:alert_receive_channel-list')}?integration_ne={AlertReceiveChannel.INTEGRATION_DIRECT_PAGING}"
response = client.get(url, format="json", **make_user_auth_headers(user, token))
results = response.json()["results"]
assert response.status_code == status.HTTP_200_OK
assert len(results) == 2
for result in results:
assert result["integration"] != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
@pytest.mark.django_db
@pytest.mark.parametrize(
"query_param,should_be_unpaginated",

View file

@ -262,6 +262,31 @@ def test_list_users_filtered_by_granted_permission(
assert user3.public_primary_key not in returned_user_pks
@pytest.mark.django_db
def test_list_users_filtered_by_public_primary_key(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
):
organization = make_organization()
admin_user = make_user_for_organization(organization)
user1 = make_user_for_organization(organization)
make_user_for_organization(organization)
_, token = make_token_for_organization(organization)
client = APIClient()
url = reverse("api-internal:user-list")
response = client.get(
f"{url}?search={user1.public_primary_key}", format="json", **make_user_auth_headers(admin_user, token)
)
assert response.status_code == status.HTTP_200_OK
returned_user_pks = [u["pk"] for u in response.json()["results"]]
assert returned_user_pks == [user1.public_primary_key]
@pytest.mark.django_db
def test_notification_chain_verbal(
make_organization,

View file

@ -43,6 +43,9 @@ class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
choices=AlertReceiveChannel.MAINTENANCE_MODE_CHOICES, method="filter_maintenance_mode"
)
integration = filters.MultipleChoiceFilter(choices=AlertReceiveChannel.INTEGRATION_CHOICES)
integration_ne = filters.MultipleChoiceFilter(
choices=AlertReceiveChannel.INTEGRATION_CHOICES, field_name="integration", exclude=True
)
team = TeamModelMultipleChoiceFilter()
class Meta:

View file

@ -222,6 +222,7 @@ class UserView(
"^slack_user_identity__cached_slack_login",
"^slack_user_identity__cached_name",
"^teams__name",
"=public_primary_key",
)
filterset_class = UserFilter

View file

@ -2,11 +2,24 @@ from unittest.mock import call, patch
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import OperationalError
from django.urls import reverse
from pytest_django.plugin import _DatabaseBlocker
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel
from apps.integrations.mixins import AlertChannelDefiningMixin
class DatabaseBlocker(_DatabaseBlocker):
"""Customize pytest_django db blocker to raise OperationalError exception."""
def _blocking_wrapper(*args, **kwargs):
__tracebackhide__ = True
__tracebackhide__ # Silence pyflakes
# mimic DB unavailable error
raise OperationalError("Database access disabled")
@pytest.mark.django_db
@ -184,3 +197,99 @@ def test_integration_universal_endpoint_not_allow_files(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not mock_create_alert.apply_async.called
@patch("apps.integrations.views.create_alert")
@pytest.mark.parametrize(
"integration_type",
[
arc_type
for arc_type in AlertReceiveChannel.INTEGRATION_TYPES
if arc_type not in ["amazon_sns", "grafana", "alertmanager", "grafana_alerting", "maintenance"]
],
)
@pytest.mark.django_db
def test_integration_universal_endpoint_works_without_db(
mock_create_alert, make_organization_and_user, make_alert_receive_channel, integration_type
):
organization, user = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(
organization=organization,
author=user,
integration=integration_type,
)
client = APIClient()
url = reverse(
"integrations:universal",
kwargs={"integration_type": integration_type, "alert_channel_key": alert_receive_channel.token},
)
# populate cache
AlertChannelDefiningMixin().update_alert_receive_channel_cache()
# disable DB access
with DatabaseBlocker().block():
data = {"foo": "bar"}
response = client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
mock_create_alert.apply_async.assert_called_once_with(
[],
{
"title": None,
"message": None,
"image_url": None,
"link_to_upstream_details": None,
"alert_receive_channel_pk": alert_receive_channel.pk,
"integration_unique_data": None,
"raw_request_data": data,
},
)
@patch("apps.integrations.views.create_alertmanager_alerts")
@pytest.mark.django_db
def test_integration_grafana_endpoint_without_db_has_alerts(
mock_create_alertmanager_alerts, settings, make_organization_and_user, make_alert_receive_channel
):
settings.DEBUG = False
integration_type = "grafana"
organization, user = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(
organization=organization,
author=user,
integration=integration_type,
)
client = APIClient()
url = reverse("integrations:grafana", kwargs={"alert_channel_key": alert_receive_channel.token})
data = {
"alerts": [
{
"foo": 123,
},
{
"foo": 456,
},
]
}
# populate cache
AlertChannelDefiningMixin().update_alert_receive_channel_cache()
# disable DB access
with DatabaseBlocker().block():
response = client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
mock_create_alertmanager_alerts.apply_async.assert_has_calls(
[
call((alert_receive_channel.pk, data["alerts"][0])),
call((alert_receive_channel.pk, data["alerts"][1])),
]
)

View file

@ -10,7 +10,7 @@ from common.utils import UniqueFaker
class LabelKeyFactory(factory.DjangoModelFactory):
id = UniqueFaker("sentence", nb_words=3)
id = UniqueFaker("pystr", max_chars=36)
name = UniqueFaker("sentence", nb_words=3)
class Meta:
@ -18,7 +18,7 @@ class LabelKeyFactory(factory.DjangoModelFactory):
class LabelValueFactory(factory.DjangoModelFactory):
id = UniqueFaker("sentence", nb_words=3)
id = UniqueFaker("pystr", max_chars=36)
name = UniqueFaker("sentence", nb_words=3)
class Meta:

View file

@ -53,7 +53,8 @@ class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin):
class Meta:
model = User
fields = ["id", "email", "slack", "username", "role", "is_phone_number_verified"]
fields = ["id", "email", "slack", "username", "role", "is_phone_number_verified", "timezone"]
read_only_fields = ["timezone"]
@staticmethod
def get_role(obj):

View file

@ -34,6 +34,7 @@ def test_get_user(
"username": user.username,
"role": "admin",
"is_phone_number_verified": False,
"timezone": user.timezone,
}
assert response.status_code == status.HTTP_200_OK
@ -72,6 +73,7 @@ def test_get_users_list(
"username": user_1.username,
"role": "admin",
"is_phone_number_verified": False,
"timezone": user_1.timezone,
},
{
"id": user_2.public_primary_key,
@ -80,6 +82,7 @@ def test_get_users_list(
"username": user_2.username,
"role": "admin",
"is_phone_number_verified": False,
"timezone": user_2.timezone,
},
],
"current_page_number": 1,

View file

@ -57,6 +57,11 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args
strategy.session[REDIRECT_FIELD_NAME] = url
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
# at this point everything is correct and we can create the SlackUserIdentity
# be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want
# to be sure to clear these so they do not see them again
strategy.session.flush()
slack_user_identity, _ = SlackUserIdentity.objects.get_or_create(
slack_id=slack_user_id,
slack_team_identity=slack_team_identity,

View file

@ -32,6 +32,11 @@ class OrganizationMovedMiddleware(MiddlewareMixin):
if (v := request.META.get("HTTP_AUTHORIZATION", None)) is not None:
headers["Authorization"] = v
if "amazon_sns" in request.path:
for k, v in request.META.items():
if k.startswith("x-amz-sns-"):
headers[k] = v
response = self.make_request(request.method, url, headers, request.body)
return HttpResponse(response.content, status=response.status_code)

View file

@ -238,3 +238,36 @@ def test_user_schedule_export_token_raises_exception_organization_moved(
assert False
except OrganizationMovedException as e:
assert e.organization == organization
@patch("apps.user_management.middlewares.OrganizationMovedMiddleware.make_request")
@pytest.mark.django_db
def test_organization_moved_middleware_amazon_sns_headers(
mocked_make_request, make_organization_and_region, make_alert_receive_channel
):
organization, region = make_organization_and_region()
organization.save()
alert_receive_channel = make_alert_receive_channel(
organization=organization,
integration="amazon_sns",
)
expected_sns_headers = {
"x-amz-sns-subscription-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test:3aab6edb-0c5e-4fa9-b876-64409d1f6c63",
"x-amz-sns-topic-arn": "arn:aws:sns:xxxxxxxxxx:467989492352:oncall-test",
"x-amz-sns-message-id": "473efe1d-8ea4-5252-8124-a3d5ff7408c5",
"x-amz-sns-message-type": "Notification",
}
expected_message = bytes(f"Redirected to {region.oncall_backend_url}", "utf-8")
mocked_make_request.return_value = HttpResponse(expected_message, status=status.HTTP_200_OK)
client = APIClient()
url = reverse("integrations:amazon_sns", kwargs={"alert_channel_key": alert_receive_channel.token})
data = {"value": "test"}
response = client.post(url, data, format="json", **expected_sns_headers)
assert mocked_make_request.called
assert expected_sns_headers.items() <= mocked_make_request.call_args.args[2].items()
assert response.content == expected_message
assert response.status_code == status.HTTP_200_OK

View file

@ -36,6 +36,7 @@ module.exports = {
'newlines-between': 'always',
},
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': [
'warn',
{

View file

@ -15,7 +15,7 @@ test.describe("updating an integration's heartbeat interval works", async () =>
};
test('change heartbeat interval', async ({ adminRolePage: { page } }) => {
await createIntegration(page, generateRandomValue());
await createIntegration({ page, integrationName: generateRandomValue() });
await _openHeartbeatSettingsForm(page);
@ -43,7 +43,7 @@ test.describe("updating an integration's heartbeat interval works", async () =>
});
test('send heartbeat', async ({ adminRolePage: { page } }) => {
await createIntegration(page, generateRandomValue());
await createIntegration({ page, integrationName: generateRandomValue() });
await _openHeartbeatSettingsForm(page);

View file

@ -0,0 +1,47 @@
import { test, expect } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations';
test('Integrations table shows data in Connections and Direct Paging tabs', async ({ adminRolePage: { page } }) => {
// // Create 2 integrations that are not Direct Paging
const ID = generateRandomValue();
const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`;
const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`;
const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging`;
await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME });
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
await createIntegration({
page,
integrationSearchText: 'Alertmanager',
shouldGoToIntegrationsPage: false,
integrationName: ALERTMANAGER_INTEGRATION_NAME,
});
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// Create 1 Direct Paging integration if it doesn't exist
const integrationsTable = page.getByTestId('integrations-table');
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
const isDirectPagingAlreadyCreated = await page.getByText('Direct paging').isVisible();
if (!isDirectPagingAlreadyCreated) {
await createIntegration({
page,
integrationSearchText: 'Direct paging',
shouldGoToIntegrationsPage: false,
integrationName: DIRECT_PAGING_INTEGRATION_NAME,
});
}
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// By default Connections tab is opened and newly created integrations are visible except Direct Paging one
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable).not.toContainText(DIRECT_PAGING_INTEGRATION_NAME);
// Then after switching to Direct Paging tab only Direct Paging integration is visible
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable).toContainText(DIRECT_PAGING_INTEGRATION_NAME);
});

View file

@ -106,7 +106,7 @@ test.describe('maintenance mode works', () => {
const integrationName = generateRandomValue();
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegration(page, integrationName);
await createIntegration({ page, integrationName });
await assignEscalationChainToIntegration(page, escalationChainName);
await enableMaintenanceMode(page, maintenanceModeType);

View file

@ -32,7 +32,7 @@ export const createEscalationChain = async (
await page.locator('text=Loading...').waitFor({ state: 'detached' });
// open the create escalation chain modal
(await page.waitForSelector('text=New Escalation Chain')).click();
(await page.waitForSelector('text=/New Escalation Chain/i')).click();
// fill in the name input
await fillInInput(page, 'div[data-testid="create-escalation-chain-name-input-modal"] >> input', escalationChainName);
@ -44,7 +44,10 @@ export const createEscalationChain = async (
if (escalationStep) {
// add an escalation step
await selectDropdownValue({
page, selectType: 'grafanaSelect', placeholderText: 'Add escalation step...', value: escalationStep,
page,
selectType: 'grafanaSelect',
placeholderText: 'Add escalation step...',
value: escalationStep,
});
// toggle important
@ -52,13 +55,15 @@ export const createEscalationChain = async (
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: "Default",
value: "Important",
placeholderText: 'Default',
value: 'Important',
});
}
// select the escalation step value (e.g. user or schedule)
if (escalationStepValue) {await selectEscalationStepValue(page, escalationStep, escalationStepValue);}
if (escalationStepValue) {
await selectEscalationStepValue(page, escalationStep, escalationStepValue);
}
}
};

View file

@ -1,25 +1,40 @@
import { Page } from '@playwright/test';
import { clickButton, selectDropdownValue } from './forms';
import { clickButton, generateRandomValue, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]';
export const openCreateIntegrationModal = async (page: Page): Promise<void> => {
// go to the integrations page
await goToOnCallPage(page, 'integrations');
// open the create integration modal
(await page.waitForSelector('text=New integration')).click();
await page.getByRole('button', { name: 'New integration' }).click();
// wait for it to pop up
await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR);
await page.getByTestId('create-integration-modal').waitFor();
};
export const createIntegration = async (page: Page, integrationName: string): Promise<void> => {
export const createIntegration = async ({
page,
integrationName = `integration-${generateRandomValue()}`,
integrationSearchText = 'Webhook',
shouldGoToIntegrationsPage = true,
}: {
page: Page;
integrationName?: string;
integrationSearchText?: string;
shouldGoToIntegrationsPage?: boolean;
}): Promise<void> => {
if (shouldGoToIntegrationsPage) {
// go to the integrations page
await goToOnCallPage(page, 'integrations');
}
await openCreateIntegrationModal(page);
// create a webhook integration
(await page.waitForSelector(`${CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR} >> text=Webhook`)).click();
// create an integration
await page
.getByTestId('create-integration-modal')
.getByTestId('integration-display-name')
.filter({ hasText: integrationSearchText })
.first()
.click();
// fill in the required inputs
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName);
@ -55,7 +70,7 @@ export const createIntegrationAndSendDemoAlert = async (
integrationName: string,
escalationChainName: string
): Promise<void> => {
await createIntegration(page, integrationName);
await createIntegration({ page, integrationName });
await assignEscalationChainToIntegration(page, escalationChainName);
await sendDemoAlert(page);
};

View file

@ -13,6 +13,8 @@
"test:silent": "jest --silent",
"test:e2e": "yarn playwright test --grep-invert @expensive",
"test:e2e-expensive": "yarn playwright test --grep @expensive",
"test:e2e:watch": "yarn test:e2e --ui",
"test:e2e:gen": "yarn playwright codegen http://localhost:3000",
"cleanup-e2e-results": "rm -rf playwright-report && rm -rf test-results",
"e2e-show-report": "yarn playwright show-report",
"dev": "grafana-toolkit plugin:dev",
@ -126,13 +128,13 @@
"mobx-react": "6.1.1",
"object-hash": "^3.0.0",
"prettier": "^2.8.2",
"qrcode.react": "^3.1.0",
"raw-loader": "^4.0.2",
"rc-table": "^7.17.1",
"react-copy-to-clipboard": "^5.0.2",
"react-draggable": "^4.4.5",
"react-emoji-render": "^1.2.4",
"react-modal": "^3.15.1",
"react-qr-code": "^2.0.8",
"react-responsive": "^8.1.0",
"react-router-dom": "5.3.3",
"react-sortable-hoc": "^1.11.0",

View file

@ -71,7 +71,7 @@
--working-hours-shades-color-light: rgba(17, 18, 23, 0.04);
}
.theme-dark {
.theme-dark:not(.theme-light) {
--cards-background: var(--gray-9);
--highlighted-row-bg: var(--gray-9);
--disabled-button-color: hsla(0, 0%, 100%, 0.08);

View file

@ -1,4 +1,6 @@
.add-responders-dropdown {
max-height: 500px;
overflow: hidden;
border: var(--border-medium);
position: absolute;
right: 16px;
@ -8,7 +10,7 @@
z-index: 10;
}
.team-direct-paging-info-alert {
.info-alert {
margin: 8px;
}

View file

@ -10,6 +10,7 @@ import GTable from 'components/GTable/GTable';
import Text from 'components/Text/Text';
import { Alert as AlertType } from 'models/alertgroup/alertgroup.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { PaginatedUsersResponse } from 'models/user/user';
import { UserCurrentlyOnCall } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks';
@ -51,12 +52,11 @@ const AddRespondersPopup = observer(
const [searchLoading, setSearchLoading] = useState<boolean>(true);
const [activeOption, setActiveOption] = useState<TabOptions>(isCreateMode ? TabOptions.Teams : TabOptions.Users);
const [teamSearchResults, setTeamSearchResults] = useState<GrafanaTeam[]>([]);
const [userSearchResults, setUserSearchResults] = useState<UserCurrentlyOnCall[]>([]);
const [onCallUserSearchResults, setOnCallUserSearchResults] = useState<UserCurrentlyOnCall[]>([]);
const [notOnCallUserSearchResults, setNotOnCallUserSearchResults] = useState<UserCurrentlyOnCall[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const ref = useRef();
const usersCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => is_currently_oncall);
const usersNotCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => !is_currently_oncall);
useOnClickOutside(ref, () => {
setVisible(false);
@ -97,11 +97,18 @@ const AddRespondersPopup = observer(
);
const searchForUsers = useCallback(async () => {
/**
* specifying is_currently_oncall=all will tell the backend not to paginate the results
*/
const userResults = await userStore.search<UserCurrentlyOnCall[]>({ searchTerm, is_currently_oncall: 'all' });
setUserSearchResults(userResults);
const _search = async (is_currently_oncall: boolean) => {
const response = await userStore.search<PaginatedUsersResponse<UserCurrentlyOnCall>>({
searchTerm,
is_currently_oncall,
});
return response.results;
};
const [onCallUserSearchResults, notOnCallUserSearchResults] = await Promise.all([_search(true), _search(false)]);
setOnCallUserSearchResults(onCallUserSearchResults);
setNotOnCallUserSearchResults(notOnCallUserSearchResults);
}, [searchTerm]);
const searchForTeams = useCallback(async () => {
@ -153,9 +160,12 @@ const AddRespondersPopup = observer(
useEffect(() => {
if (existingPagedUsers.length > 0) {
const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk);
setUserSearchResults((userSearchResults) =>
userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk))
);
const _filterUsers = (users: UserCurrentlyOnCall[]) =>
users.filter(({ pk }) => !existingPagedUserIds.includes(pk));
setOnCallUserSearchResults(_filterUsers);
setNotOnCallUserSearchResults(_filterUsers);
}
}, [existingPagedUsers]);
@ -293,7 +303,7 @@ const AddRespondersPopup = observer(
) : (
<>
<Alert
className={cx('team-direct-paging-info-alert')}
className={cx('info-alert')}
severity="info"
title={
(
@ -330,8 +340,22 @@ const AddRespondersPopup = observer(
)}
{!searchLoading && activeOption === TabOptions.Users && (
<>
<UserResultsSection header="On-call now" users={usersCurrentlyOnCall} />
<UserResultsSection header="Not on-call" users={usersNotCurrentlyOnCall} />
<Alert
className={cx('info-alert')}
severity="info"
title={
(
<Text type="primary">
We display a maximum of 100 users per category. Use the search bar above to refine results. You
can search by username, email, or team name.
</Text>
) as any
}
/>
<UserResultsSection header="On-call now" users={onCallUserSearchResults} />
<div style={{ marginTop: '10px' }}>
<UserResultsSection header="Not on-call" users={notOnCallUserSearchResults} />
</div>
</>
)}
</div>

View file

@ -21,11 +21,8 @@ interface LabelsFilterProps {
const LabelsFilter = observer((props: LabelsFilterProps) => {
const { filterType, className, autoFocus, value: propsValue, onChange } = props;
const [value, setValue] = useState([]);
const [keys, setKeys] = useState([]);
const { alertGroupStore, labelsStore } = useStore();
const loadKeys =
@ -44,9 +41,7 @@ const LabelsFilter = observer((props: LabelsFilterProps) => {
useEffect(() => {
const keyValuePairs = (propsValue || []).map((k) => k.split(':'));
const promises = keyValuePairs.map(([keyId]) => loadValuesForKey(keyId));
const fetchKeyValues = async () => await Promise.all(promises);
fetchKeyValues().then((list) => {

View file

@ -152,7 +152,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
App connected <Icon name="check-circle" size="md" className={cx('icon')} />
</Text>
<Text type="primary">
You can sync one application to your account. To setup new device please disconnect app first.
You can only sync one application to your account. To setup a new device, please disconnect the currently
connected device first.
</Text>
<div className={cx('disconnect__container')}>
<img src={qrCodeImage} className={cx('disconnect__qrCode')} />
@ -168,7 +169,9 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
<Text type="primary" strong>
Sign In
</Text>
<Text type="primary">Open Grafana IRM mobile application and scan this code to sync it with your account.</Text>
<Text type="primary">
Open the Grafana OnCall mobile application and scan this code to sync it with your account.
</Text>
<div className={cx('u-width-100', 'u-flex', 'u-flex-center', 'u-position-relative')}>
<QRCode className={cx({ 'qr-code': true, blurry: isQRBlurry })} value={QRCodeValue} />
{isQRBlurry && <QRLoading />}

View file

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import QRCodeBase from 'react-qr-code';
import { QRCodeSVG } from 'qrcode.react';
import Block from 'components/GBlock/Block';
@ -14,7 +14,7 @@ const QRCode: FC<Props> = (props: Props) => {
return (
<Block bordered className={className}>
<QRCodeBase value={value} />
<QRCodeSVG value={value} size={256} />
</Block>
);
};

View file

@ -44,6 +44,7 @@ interface RemoteFiltersProps extends WithStoreProps {
defaultFilters?: FiltersValues;
extraFilters?: (state, setState, onFiltersValueChange) => React.ReactNode;
grafanaTeamStore: GrafanaTeamStore;
skipFilterOptionFn?: (filterOption: FilterOption) => boolean;
}
interface RemoteFiltersState {
filterOptions?: FilterOption[];
@ -86,11 +87,16 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
page,
store: { filtersStore },
defaultFilters,
skipFilterOptionFn,
} = this.props;
const filterOptions = await filtersStore.updateOptionsForPage(page);
let filterOptions = await filtersStore.updateOptionsForPage(page);
const currentTablePageNum = parseInt(filtersStore.currentTablePageNum[page] || query.p || 1, 10);
if (skipFilterOptionFn) {
filterOptions = filterOptions.filter((option: FilterOption) => !skipFilterOptionFn(option));
}
// set the current page from filters/query or default it to 1
filtersStore.setCurrentTablePageNum(page, currentTablePageNum);

View file

@ -47,7 +47,7 @@ const SlackConnector = (props: SlackConnectorProps) => {
<Input
disabled={true}
value={
storeUser.slack_user_identity?.slack_login ? '@' + storeUser.slack_user_identity?.slack_login : ''
storeUser.slack_user_identity?.display_name ? '@' + storeUser.slack_user_identity?.display_name : ''
}
/>
<WithConfirm title="Are you sure to disconnect your Slack account?" confirmText="Disconnect">

View file

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { Button, VerticalGroup, Icon } from '@grafana/ui';
import { Button, VerticalGroup, Icon, HorizontalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
@ -48,7 +48,9 @@ export const SlackTab = () => {
</VerticalGroup>
</Block>
<Button onClick={handleClickConnectSlackAccount}>
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
<HorizontalGroup spacing="xs" align="center">
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
</HorizontalGroup>
</Button>
</VerticalGroup>
</WithPermissionControlDisplay>

View file

@ -21,6 +21,7 @@ import {
AlertReceiveChannelCounters,
ContactPoint,
MaintenanceMode,
SupportedIntegrationFilters,
} from './alert_receive_channel.types';
export class AlertReceiveChannelStore extends BaseStore {
@ -132,8 +133,17 @@ export class AlertReceiveChannelStore extends BaseStore {
return results;
}
async updatePaginatedItems(query: any = '', page = 1, updateCounters = false, invalidateFn = undefined) {
const filters = typeof query === 'string' ? { search: query } : query;
async updatePaginatedItems({
filters,
page = 1,
updateCounters = false,
invalidateFn = undefined,
}: {
filters: SupportedIntegrationFilters;
page: number;
updateCounters: boolean;
invalidateFn: () => boolean;
}) {
const { count, results, page_size } = await makeRequest(this.path, { params: { ...filters, page } });
if (invalidateFn?.()) {

View file

@ -62,3 +62,11 @@ export interface ContactPoint {
contactPoint: string;
notificationConnected: boolean;
}
export interface SupportedIntegrationFilters {
integration?: string[];
integration_ne?: string[];
team?: string[];
label?: string[];
searchTerm?: string;
}

View file

@ -15,7 +15,7 @@ import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { getTimezone, prepareForUpdate } from './user.helpers';
import { User } from './user.types';
type PaginatedUsersResponse<UT = User> = {
export type PaginatedUsersResponse<UT = User> = {
count: number;
page_size: number;
results: UT[];

View file

@ -34,6 +34,7 @@ export interface User extends BaseUser {
unverified_phone_number?: string;
slack_user_identity: {
avatar: string;
display_name: string;
name: string;
slack_id: string;
slack_login: string;

View file

@ -7,6 +7,10 @@
width: 40px;
}
.tabsBar {
margin-bottom: 24px;
}
.integrations-header {
margin-bottom: 24px;
right: 0;
@ -45,3 +49,7 @@
background: var(--cards-background);
}
}
.goToDirectPagingAlert {
margin-top: 24px;
}

View file

@ -1,7 +1,18 @@
import React from 'react';
import { LabelTag } from '@grafana/labels';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui';
import {
HorizontalGroup,
Button,
VerticalGroup,
Icon,
ConfirmModal,
Tooltip,
Tab,
TabsBar,
TabContent,
Alert,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -28,7 +39,11 @@ import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import {
AlertReceiveChannel,
MaintenanceMode,
SupportedIntegrationFilters,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelKeyValue } from 'models/label/label.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
@ -41,11 +56,29 @@ import { PAGE, TEXT_ELLIPSIS_CLASS } from 'utils/consts';
import styles from './Integrations.module.scss';
enum TabType {
Connections = 'connections',
DirectPaging = 'direct-paging',
}
const TAB_QUERY_PARAM_KEY = 'tab';
const TABS = [
{
label: 'Connections',
value: TabType.Connections,
},
{
label: 'Direct Paging',
value: TabType.DirectPaging,
},
];
const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Record<string, any>;
integrationsFilters: SupportedIntegrationFilters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
confirmationModal: {
isOpen: boolean;
@ -57,6 +90,7 @@ interface IntegrationsState extends PageBaseState {
confirmationText?: string;
onConfirm: () => void;
};
activeTab: TabType;
}
interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -67,9 +101,10 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
super(props);
this.state = {
integrationsFilters: { searchTerm: '' },
integrationsFilters: { searchTerm: '', integration_ne: ['direct_paging'] },
errorData: initErrorDataState(),
confirmationModal: undefined,
activeTab: props.query[TAB_QUERY_PARAM_KEY] || TabType.Connections,
};
}
@ -81,14 +116,12 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
if (prevProps.match.params.id !== this.props.match.params.id) {
this.parseQueryParams();
}
if (prevProps.query[TAB_QUERY_PARAM_KEY] !== this.props.query[TAB_QUERY_PARAM_KEY]) {
this.onTabChange(this.props.query[TAB_QUERY_PARAM_KEY] as TabType);
}
}
parseQueryParams = async () => {
this.setState((_prevState) => ({
errorData: initErrorDataState(),
alertReceiveChannelId: undefined,
})); // reset state on query parse
const {
store,
match: {
@ -96,6 +129,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
},
} = this.props;
this.setState((_prevState) => ({
errorData: initErrorDataState(),
alertReceiveChannelId: undefined,
})); // reset state on query parse
if (!id) {
return;
}
@ -114,24 +152,52 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
};
getFiltersBasedOnCurrentTab = () => ({
...this.state.integrationsFilters,
...(this.state.activeTab === TabType.DirectPaging
? { integration: ['direct_paging'] }
: {
integration_ne: ['direct_paging'],
integration: this.state.integrationsFilters.integration?.filter(
(integration) => integration !== 'direct_paging'
),
}),
});
update = () => {
const { store } = this.props;
const { integrationsFilters } = this.state;
const page = store.filtersStore.currentTablePageNum[PAGE.Integrations];
LocationHelper.update({ p: page }, 'partial');
return store.alertReceiveChannelStore.updatePaginatedItems(integrationsFilters, page, false, () =>
this.invalidateRequestFn(page)
return store.alertReceiveChannelStore.updatePaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page,
updateCounters: false,
invalidateFn: () => this.invalidateRequestFn(page),
});
};
onTabChange = (tab: TabType) => {
LocationHelper.update({ tab, integration: undefined, search: undefined }, 'partial');
this.setState(
{
activeTab: tab,
},
() => {
this.handleChangePage(1);
}
);
};
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, confirmationModal } = this.state;
const { alertReceiveChannelId, confirmationModal, activeTab, integrationsFilters } = this.state;
const { alertReceiveChannelStore } = store;
const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult();
const isDirectPagingSelectedOnConnectionsTab =
activeTab === TabType.Connections && integrationsFilters.integration?.includes('direct_paging');
return (
<>
@ -158,27 +224,58 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
</HorizontalGroup>
</div>
<div>
<RemoteFilters
query={query}
page={PAGE.Integrations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
/>
<GTable
emptyText={count === undefined ? 'Loading...' : 'No integrations found'}
loading={count === undefined}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={this.getTableColumns(store.hasFeature.bind(store))}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
page: store.filtersStore.currentTablePageNum[PAGE.Integrations],
total: results ? Math.ceil((count || 0) / page_size) : 0,
onChange: this.handleChangePage,
}}
/>
<TabsBar className={cx('tabsBar')}>
{TABS.map(({ label, value }) => (
<Tab
key={value}
label={label}
active={activeTab === value}
onChangeTab={() => this.onTabChange(value)}
/>
))}
</TabsBar>
<TabContent>
<RemoteFilters
key={activeTab} // added to remount the component on each tab
query={query}
page={PAGE.Integrations}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleIntegrationsFiltersChange}
{...(activeTab === TabType.DirectPaging && {
skipFilterOptionFn: ({ name }) => name === 'integration',
})}
/>
{isDirectPagingSelectedOnConnectionsTab && (
<Alert
className={cx('goToDirectPagingAlert')}
severity="info"
title="Direct Paging integrations have been moved."
>
<span>
They are in a separate tab now. Go to{' '}
<PluginLink query={{ page: 'integrations', tab: TabType.DirectPaging }}>
Direct Paging tab
</PluginLink>{' '}
to view them.
</span>
</Alert>
)}
<GTable
emptyText={count === undefined ? 'Loading...' : 'No integrations found'}
loading={count === undefined}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={this.getTableColumns(store.hasFeature.bind(store))}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
page: store.filtersStore.currentTablePageNum[PAGE.Integrations],
total: results ? Math.ceil((count || 0) / page_size) : 0,
onChange: this.handleChangePage,
}}
/>
</TabContent>
</div>
</div>
@ -455,6 +552,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
getTableColumns = (hasFeatureFn) => {
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
const isConnectionsTab = this.state.activeTab === TabType.Connections;
const columns = [
{
@ -476,21 +574,24 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
},
...(isConnectionsTab
? [
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
]
: []),
{
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
width: isConnectionsTab ? '15%' : '30%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
},
@ -572,12 +673,15 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
applyFilters = async (isOnMount: boolean) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
const { integrationsFilters } = this.state;
const newPage = isOnMount ? store.filtersStore.currentTablePageNum[PAGE.Integrations] : 1;
return alertReceiveChannelStore
.updatePaginatedItems(integrationsFilters, newPage, false, () => this.invalidateRequestFn(newPage))
.updatePaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page: newPage,
updateCounters: false,
invalidateFn: () => this.invalidateRequestFn(newPage),
})
.then(() => {
store.filtersStore.currentTablePageNum[PAGE.Integrations] = newPage;
LocationHelper.update({ p: newPage }, 'partial');

View file

@ -289,7 +289,9 @@ class SlackSettings extends Component<SlackProps, SlackState> {
) : (
<HorizontalGroup>
<Button onClick={this.handleOpenSlackInstructions}>
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
<HorizontalGroup spacing="xs" align="center">
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
</HorizontalGroup>
</Button>
{store.hasFeature(AppFeature.LiveSettings) && (
<PluginLink query={{ page: 'live-settings' }}>

View file

@ -12475,10 +12475,10 @@ punycode@^2.1.0, punycode@^2.1.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
qr.js@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f"
integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==
qrcode.react@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8"
integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==
query-string@*:
version "7.1.1"
@ -13224,14 +13224,6 @@ react-popper@2.3.0, react-popper@^2.3.0:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-qr-code@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.8.tgz#d34a766fb5b664a40dbdc7020f7ac801bacb2851"
integrity sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA==
dependencies:
prop-types "^15.8.1"
qr.js "0.0.0"
react-redux@^7.2.0:
version "7.2.9"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"