v1.3.58
This commit is contained in:
commit
4254f82ead
43 changed files with 673 additions and 4542 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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 %}}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ module.exports = {
|
|||
'newlines-between': 'always',
|
||||
},
|
||||
],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?.()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue