Improve performance of GET /users and GET /teams endpoints used by add responders popup (#3241)
# What this PR does
- Improve performance of the specific `GET /users` and `GET /teams`
calls that're made by the Add Responders dropdown in the UI
- Add `GET /team/{teamId}` internal API route (needed by Grafana
Incident team for their Add Responders changes)
- Some UI improvements to the Add Responders popup (loading state +
pre-fetch users and teams when the drawer is opened)
- Re-enable django-admin only if `settings.SILK_PROFILER_ENABLED ==
True` (need to be able to log into django admin to auth to silk routes)
Closes #3231
Closes https://github.com/grafana/oncall-private/issues/2252
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
parent
fb3bc0d7e5
commit
2cbb20601e
25 changed files with 428 additions and 585 deletions
|
|
@ -197,28 +197,3 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
|
|||
def user_is_oncall(user: User) -> bool:
|
||||
schedules_with_oncall_users = get_oncall_users_for_multiple_schedules(OnCallSchedule.objects.related_to_user(user))
|
||||
return user.pk in {user.pk for _, users in schedules_with_oncall_users.items() for user in users}
|
||||
|
||||
|
||||
def integration_is_notifiable(integration: AlertReceiveChannel) -> bool:
|
||||
"""
|
||||
Returns true if:
|
||||
- the integration has more than one channel filter associated with it
|
||||
- the default channel filter has at least one notification method specified or an escalation chain associated with it
|
||||
"""
|
||||
if integration.channel_filters.count() > 1:
|
||||
return True
|
||||
|
||||
default_channel_filter = integration.default_channel_filter
|
||||
if not default_channel_filter:
|
||||
return False
|
||||
|
||||
organization = integration.organization
|
||||
notify_via_slack = organization.slack_is_configured and default_channel_filter.notify_in_slack
|
||||
notify_via_telegram = organization.telegram_is_configured and default_channel_filter.notify_in_telegram
|
||||
|
||||
notify_via_chatops = notify_via_slack or notify_via_telegram
|
||||
custom_messaging_backend_configured = default_channel_filter.notification_backends is not None
|
||||
|
||||
return (
|
||||
default_channel_filter.escalation_chain is not None or notify_via_chatops or custom_messaging_backend_configured
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from apps.alerts.paging import (
|
|||
DirectPagingUserTeamValidationError,
|
||||
_construct_title,
|
||||
direct_paging,
|
||||
integration_is_notifiable,
|
||||
unpage_user,
|
||||
user_is_oncall,
|
||||
)
|
||||
|
|
@ -292,67 +291,3 @@ def test_construct_title(make_organization, make_team, make_user_for_organizatio
|
|||
assert _construct_title(from_user, team, multiple_users) == _title(
|
||||
f"{team.name}, {user1.username}, {user2.username} and {user3.username}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_integration_is_notifiable(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_escalation_chain,
|
||||
make_slack_team_identity,
|
||||
make_telegram_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
|
||||
# integration has no default channel filter
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(arc, is_default=False)
|
||||
assert integration_is_notifiable(arc) is False
|
||||
|
||||
# integration has more than one channel filter
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(arc, is_default=False)
|
||||
make_channel_filter(arc, is_default=False)
|
||||
assert integration_is_notifiable(arc) is True
|
||||
|
||||
# integration's default channel filter is setup to notify via slack but Slack is not configured for the org
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=True)
|
||||
assert integration_is_notifiable(arc) is False
|
||||
|
||||
# integration's default channel filter is setup to notify via slack and Slack is configured for the org
|
||||
arc = make_alert_receive_channel(organization)
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=True)
|
||||
assert integration_is_notifiable(arc) is True
|
||||
|
||||
# integration's default channel filter is setup to notify via telegram but Telegram is not configured for the org
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True)
|
||||
assert integration_is_notifiable(arc) is False
|
||||
|
||||
# integration's default channel filter is setup to notify via telegram and Telegram is configured for the org
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True)
|
||||
make_telegram_channel(organization)
|
||||
assert integration_is_notifiable(arc) is True
|
||||
|
||||
# integration's default channel filter is contactable via a custom messaging backend
|
||||
arc = make_alert_receive_channel(organization)
|
||||
make_channel_filter(
|
||||
arc,
|
||||
is_default=True,
|
||||
notify_in_slack=False,
|
||||
notification_backends={"MSTEAMS": {"channel": "test", "enabled": True}},
|
||||
)
|
||||
assert integration_is_notifiable(arc) is True
|
||||
|
||||
# integration's default channel filter has an escalation chain attached to it
|
||||
arc = make_alert_receive_channel(organization)
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, escalation_chain=escalation_chain)
|
||||
assert integration_is_notifiable(arc) is True
|
||||
|
|
|
|||
|
|
@ -270,14 +270,19 @@ class UserShortSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class UserLongSerializer(UserSerializer):
|
||||
class UserIsCurrentlyOnCallSerializer(UserShortSerializer, EagerLoadingMixin):
|
||||
context: UserSerializerContext
|
||||
|
||||
teams = FastTeamSerializer(read_only=True, many=True)
|
||||
is_currently_oncall = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(UserSerializer.Meta):
|
||||
fields = UserSerializer.Meta.fields + [
|
||||
SELECT_RELATED = ["organization"]
|
||||
PREFETCH_RELATED = ["teams"]
|
||||
|
||||
class Meta(UserShortSerializer.Meta):
|
||||
fields = UserShortSerializer.Meta.fields + [
|
||||
"name",
|
||||
"timezone",
|
||||
"teams",
|
||||
"is_currently_oncall",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,25 @@ def get_payload_from_team(team, long=False):
|
|||
return payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_team(make_organization_and_user_with_plugin_token, make_team, make_user_auth_headers):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
team = make_team(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# team exists
|
||||
url = reverse("api-internal:team-detail", kwargs={"pk": team.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == get_payload_from_team(team)
|
||||
|
||||
# 404 scenario
|
||||
url = reverse("api-internal:team-detail", kwargs={"pk": "asdfasdflkjlkajsdf"})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_teams(
|
||||
make_organization,
|
||||
|
|
@ -100,7 +119,20 @@ def test_list_teams_only_include_notifiable_teams(
|
|||
client = APIClient()
|
||||
url = reverse("api-internal:team-list")
|
||||
|
||||
with patch("apps.api.views.team.integration_is_notifiable", side_effect=lambda obj: obj.id == arc1.id):
|
||||
def mock_get_notifiable_direct_paging_integrations():
|
||||
class MockRelatedManager:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def values_list(self, *args, **kwargs):
|
||||
return [arc1.team.pk]
|
||||
|
||||
return MockRelatedManager()
|
||||
|
||||
with patch(
|
||||
"apps.user_management.models.Organization.get_notifiable_direct_paging_integrations",
|
||||
side_effect=mock_get_notifiable_direct_paging_integrations,
|
||||
):
|
||||
response = client.get(
|
||||
f"{url}?only_include_notifiable_teams=true&include_no_team=false",
|
||||
format="json",
|
||||
|
|
|
|||
|
|
@ -1935,7 +1935,7 @@ def test_users_is_currently_oncall_attribute_works_properly(
|
|||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
client = APIClient()
|
||||
url = f"{reverse('api-internal:user-list')}?short=false"
|
||||
url = f"{reverse('api-internal:user-list')}?is_currently_oncall=all"
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user1, token))
|
||||
|
||||
oncall_statuses = {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from rest_framework.filters import SearchFilter
|
|||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.alerts.paging import integration_is_notifiable
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.team import TeamLongSerializer, TeamSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
|
|
@ -14,7 +13,13 @@ from apps.user_management.models import Team
|
|||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
|
||||
|
||||
class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
class TeamViewSet(
|
||||
PublicPrimaryKeyMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
authentication_classes = (
|
||||
MobileAppAuthTokenAuthentication,
|
||||
PluginAuthentication,
|
||||
|
|
@ -61,14 +66,11 @@ class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateMod
|
|||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
if self.request.query_params.get("only_include_notifiable_teams", "false") == "true":
|
||||
# filters down to only teams that have a direct paging integration that is "notifiable"
|
||||
orgs_direct_paging_integrations = self.request.user.organization.get_direct_paging_integrations()
|
||||
notifiable_direct_paging_integrations = [
|
||||
i for i in orgs_direct_paging_integrations if integration_is_notifiable(i)
|
||||
]
|
||||
team_ids = [i.team.pk for i in notifiable_direct_paging_integrations if i.team is not None]
|
||||
|
||||
queryset = queryset.filter(pk__in=team_ids)
|
||||
queryset = queryset.filter(
|
||||
pk__in=self.request.user.organization.get_notifiable_direct_paging_integrations()
|
||||
.filter(team__isnull=False)
|
||||
.values_list("team__pk", flat=True)
|
||||
)
|
||||
|
||||
queryset = queryset.order_by("name")
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from apps.api.serializers.user import (
|
|||
CurrentUserSerializer,
|
||||
FilterUserSerializer,
|
||||
UserHiddenFieldsSerializer,
|
||||
UserLongSerializer,
|
||||
UserIsCurrentlyOnCallSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from apps.api.throttlers import (
|
||||
|
|
@ -238,20 +238,14 @@ class UserView(
|
|||
return self.request.query_params.get("is_currently_oncall", "").lower()
|
||||
|
||||
def _is_currently_oncall_request(self) -> bool:
|
||||
return self._get_is_currently_oncall_query_param() in ["true", "false"]
|
||||
|
||||
def _is_long_request(self) -> bool:
|
||||
return self.request.query_params.get("short", "true").lower() == "false"
|
||||
|
||||
def _is_currently_oncall_or_long_request(self) -> bool:
|
||||
return self._is_currently_oncall_request() or self._is_long_request()
|
||||
return self._get_is_currently_oncall_query_param() in ["true", "false", "all"]
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context.update(
|
||||
{
|
||||
"schedules_with_oncall_users": self.schedules_with_oncall_users
|
||||
if self._is_currently_oncall_or_long_request()
|
||||
if self._is_currently_oncall_request()
|
||||
else {}
|
||||
}
|
||||
)
|
||||
|
|
@ -268,8 +262,8 @@ class UserView(
|
|||
|
||||
if is_list_request and is_filters_request:
|
||||
return self.get_filter_serializer_class()
|
||||
elif is_list_request and self._is_currently_oncall_or_long_request():
|
||||
return UserLongSerializer
|
||||
elif is_list_request and self._is_currently_oncall_request():
|
||||
return UserIsCurrentlyOnCallSerializer
|
||||
|
||||
is_users_own_data = kwargs.get("pk") is not None and kwargs.get("pk") == user.public_primary_key
|
||||
has_admin_permission = user_is_authorized(user, [RBACPermission.Permissions.USER_SETTINGS_ADMIN])
|
||||
|
|
@ -296,8 +290,7 @@ class UserView(
|
|||
def _get_oncall_user_ids():
|
||||
return {user.pk for _, users in self.schedules_with_oncall_users.items() for user in users}
|
||||
|
||||
is_currently_oncall_query_param = self._get_is_currently_oncall_query_param()
|
||||
if is_currently_oncall_query_param == "true":
|
||||
if (is_currently_oncall_query_param := self._get_is_currently_oncall_query_param()) == "true":
|
||||
# client explicitly wants to filter out users that are on-call
|
||||
queryset = queryset.filter(pk__in=_get_oncall_user_ids())
|
||||
elif is_currently_oncall_query_param == "false":
|
||||
|
|
|
|||
|
|
@ -534,26 +534,54 @@ def _get_team_select_blocks(
|
|||
slack_user_identity: "SlackUserIdentity",
|
||||
organization: "Organization",
|
||||
is_selected: bool,
|
||||
value: "Team",
|
||||
value: typing.Optional["Team"],
|
||||
input_id_prefix: str,
|
||||
) -> Block.AnyBlocks:
|
||||
blocks: Block.AnyBlocks = []
|
||||
user = slack_user_identity.get_user(organization) # TODO: handle None
|
||||
teams = user.available_teams
|
||||
teams = (
|
||||
user.organization.get_notifiable_direct_paging_integrations()
|
||||
.filter(team__isnull=False)
|
||||
.values_list("team__pk", "team__name")
|
||||
)
|
||||
|
||||
direct_paging_info_msg = {
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
"*Note*: You can only page teams which have a Direct Paging integration that is configured. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team|Learn more>"
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if not teams:
|
||||
direct_paging_info_msg["elements"][0][
|
||||
"text"
|
||||
] += ". There are currently no teams which have a Direct Paging integration that is configured."
|
||||
blocks.append(direct_paging_info_msg)
|
||||
return blocks
|
||||
|
||||
team_options: typing.List[CompositionObjectOption] = []
|
||||
|
||||
initial_option_idx = 0
|
||||
for idx, team in enumerate(teams):
|
||||
if team == value:
|
||||
team_pk, team_name = team
|
||||
team_pk_str = str(team_pk)
|
||||
|
||||
if value == team_pk_str:
|
||||
initial_option_idx = idx
|
||||
team_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{team.name}",
|
||||
"text": team_name,
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{team.pk}",
|
||||
"value": team_pk_str,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -574,10 +602,11 @@ def _get_team_select_blocks(
|
|||
"optional": True,
|
||||
}
|
||||
|
||||
blocks: Block.AnyBlocks = [team_select]
|
||||
blocks.append(team_select)
|
||||
|
||||
# No context block if no team selected
|
||||
if not is_selected:
|
||||
blocks.append(direct_paging_info_msg)
|
||||
return blocks
|
||||
|
||||
team_select["element"]["initial_option"] = team_options[initial_option_idx]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.slack.scenarios.paging import (
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID,
|
||||
|
|
@ -19,6 +20,7 @@ from apps.slack.scenarios.paging import (
|
|||
Policy,
|
||||
StartDirectPaging,
|
||||
_get_organization_select,
|
||||
_get_team_select_blocks,
|
||||
)
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
|
|
@ -243,6 +245,20 @@ def test_trigger_paging_additional_responders(make_organization_and_user_with_sl
|
|||
mock_direct_paging.called_once_with(organization, user, "The Message", team, [(user, True)])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_team(make_organization_and_user_with_slack_identities, make_team):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
team = make_team(organization)
|
||||
payload = make_slack_payload(organization=organization, team=team)
|
||||
|
||||
step = FinishDirectPaging(slack_team_identity)
|
||||
with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging:
|
||||
with patch.object(step._slack_client, "api_call"):
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
mock_direct_paging.called_once_with(organization, user, "The Message", team)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_organization_select(make_organization):
|
||||
organization = make_organization(org_title="Organization", stack_slug="stack_slug")
|
||||
|
|
@ -251,3 +267,75 @@ def test_get_organization_select(make_organization):
|
|||
assert len(select["element"]["options"]) == 1
|
||||
assert select["element"]["options"][0]["value"] == str(organization.pk)
|
||||
assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_team_select_blocks(
|
||||
make_organization_and_user_with_slack_identities,
|
||||
make_team,
|
||||
make_alert_receive_channel,
|
||||
make_escalation_chain,
|
||||
make_channel_filter,
|
||||
):
|
||||
info_msg = (
|
||||
"*Note*: You can only page teams which have a Direct Paging integration that is configured. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team|Learn more>"
|
||||
)
|
||||
|
||||
input_id_prefix = "nmxcnvmnxv"
|
||||
|
||||
# no team selected - no team direct paging integrations available
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix)
|
||||
|
||||
assert len(blocks) == 1
|
||||
|
||||
context_block = blocks[0]
|
||||
assert context_block["type"] == "context"
|
||||
assert (
|
||||
context_block["elements"][0]["text"]
|
||||
== info_msg + ". There are currently no teams which have a Direct Paging integration that is configured."
|
||||
)
|
||||
|
||||
# no team selected - 1 team direct paging integration available
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
team = make_team(organization)
|
||||
arc = make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain)
|
||||
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix)
|
||||
|
||||
assert len(blocks) == 2
|
||||
input_block, context_block = blocks
|
||||
|
||||
team_option = {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)}
|
||||
|
||||
assert input_block["type"] == "input"
|
||||
assert len(input_block["element"]["options"]) == 1
|
||||
assert input_block["element"]["options"] == [team_option]
|
||||
assert context_block["elements"][0]["text"] == info_msg
|
||||
|
||||
# team selected
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
team = make_team(organization)
|
||||
arc = make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
escalation_chain = make_escalation_chain(organization)
|
||||
make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain)
|
||||
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, True, team.pk, input_id_prefix)
|
||||
|
||||
assert len(blocks) == 2
|
||||
input_block, context_block = blocks
|
||||
|
||||
team_option = {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)}
|
||||
|
||||
assert input_block["type"] == "input"
|
||||
assert len(input_block["element"]["options"]) == 1
|
||||
assert input_block["element"]["options"] == [team_option]
|
||||
assert input_block["element"]["initial_option"] == team_option
|
||||
|
||||
assert (
|
||||
context_block["elements"][0]["text"]
|
||||
== f"Integration <{arc.web_link}|{arc.verbal_name}> will be used for notification."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from urllib.parse import urljoin
|
|||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from mirage import fields as mirage_fields
|
||||
|
||||
|
|
@ -298,10 +299,32 @@ class Organization(MaintainableObject):
|
|||
new_channel=channel_name,
|
||||
)
|
||||
|
||||
def get_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']":
|
||||
def get_notifiable_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']":
|
||||
"""
|
||||
in layman's terms, this filters down an organization's integrations to ones which meet the following criterias:
|
||||
- the integration is a direct paging integration
|
||||
|
||||
AND at-least one of the following conditions are true for the integration:
|
||||
- have more than one channel filter associated with it
|
||||
- OR the organization has either Slack or Telegram configured (as the direct paging integration
|
||||
would automatically be configured to be notified via these channel(s))
|
||||
- OR the default channel filter associated with the integration has an escalation chain associated with it
|
||||
- OR the default channel filter associated with the integration is contactable via a custom
|
||||
messaging backend
|
||||
"""
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
|
||||
return self.alert_receive_channels.filter(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
return self.alert_receive_channels.annotate(
|
||||
num_channel_filters=Count("channel_filters"),
|
||||
# used to determine if the organization has telegram configured
|
||||
num_org_telegram_channels=Count("organization__telegram_channel"),
|
||||
).filter(
|
||||
Q(num_channel_filters__gt=1)
|
||||
| (Q(organization__slack_team_identity__isnull=False) | Q(num_org_telegram_channels__gt=0))
|
||||
| Q(channel_filters__is_default=True, channel_filters__escalation_chain__isnull=False)
|
||||
| Q(channel_filters__is_default=True, channel_filters__notification_backends__isnull=False),
|
||||
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
||||
)
|
||||
|
||||
@property
|
||||
def web_link(self):
|
||||
|
|
@ -312,14 +335,6 @@ class Organization(MaintainableObject):
|
|||
# It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests
|
||||
return urljoin(self.grafana_url, f"a/grafana-oncall-app/?oncall-uuid={self.uuid}")
|
||||
|
||||
@property
|
||||
def slack_is_configured(self) -> bool:
|
||||
return self.slack_team_identity is not None
|
||||
|
||||
@property
|
||||
def telegram_is_configured(self) -> bool:
|
||||
return self.telegram_channel.count() > 0
|
||||
|
||||
@classmethod
|
||||
def __str__(self):
|
||||
return f"{self.pk}: {self.org_title}"
|
||||
|
|
|
|||
|
|
@ -198,47 +198,74 @@ def test_organization_hard_delete(
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_slack_is_configured(make_organization, make_slack_team_identity):
|
||||
organization = make_organization()
|
||||
def test_get_notifiable_direct_paging_integrations(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_escalation_chain,
|
||||
make_slack_team_identity,
|
||||
make_telegram_channel,
|
||||
):
|
||||
def _make_org_and_arc(**arc_kwargs):
|
||||
org = make_organization()
|
||||
arc = make_alert_receive_channel(org, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, **arc_kwargs)
|
||||
return org, arc
|
||||
|
||||
assert organization.slack_is_configured is False
|
||||
def _assert(org, arc, should_be_returned=True):
|
||||
notifiable_direct_paging_integrations = org.get_notifiable_direct_paging_integrations()
|
||||
if should_be_returned:
|
||||
assert arc in notifiable_direct_paging_integrations
|
||||
else:
|
||||
assert arc not in notifiable_direct_paging_integrations
|
||||
|
||||
# integration has no default channel filter
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(arc, is_default=False)
|
||||
_assert(org, arc, should_be_returned=False)
|
||||
|
||||
# integration has more than one channel filter
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(arc, is_default=False)
|
||||
make_channel_filter(arc, is_default=False)
|
||||
_assert(org, arc)
|
||||
|
||||
# integration's default channel filter is setup to notify via slack but Slack is not configured for the org
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=True)
|
||||
_assert(org, arc, should_be_returned=False)
|
||||
|
||||
# integration's default channel filter is setup to notify via slack and Slack is configured for the org
|
||||
org, arc = _make_org_and_arc()
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
assert organization.slack_is_configured is True
|
||||
org.slack_team_identity = slack_team_identity
|
||||
org.save()
|
||||
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=True)
|
||||
_assert(org, arc)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_telegram_is_configured(make_organization, make_telegram_channel):
|
||||
organization = make_organization()
|
||||
assert organization.telegram_is_configured is False
|
||||
make_telegram_channel(organization)
|
||||
assert organization.telegram_is_configured is True
|
||||
# integration's default channel filter is setup to notify via telegram but Telegram is not configured for the org
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True)
|
||||
_assert(org, arc, should_be_returned=False)
|
||||
|
||||
# integration's default channel filter is setup to notify via telegram and Telegram is configured for the org
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True)
|
||||
make_telegram_channel(org)
|
||||
_assert(org, arc)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_direct_paging_integrations(make_organization, make_team, make_alert_receive_channel):
|
||||
org1 = make_organization()
|
||||
org1_team1 = make_team(org1)
|
||||
org1_team2 = make_team(org1)
|
||||
|
||||
org2 = make_organization()
|
||||
|
||||
org1_direct_paging_integration1 = make_alert_receive_channel(
|
||||
org1, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=org1_team1
|
||||
)
|
||||
org1_direct_paging_integration2 = make_alert_receive_channel(
|
||||
org1, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=org1_team2
|
||||
# integration's default channel filter is contactable via a custom messaging backend
|
||||
org, arc = _make_org_and_arc()
|
||||
make_channel_filter(
|
||||
arc,
|
||||
is_default=True,
|
||||
notify_in_slack=False,
|
||||
notification_backends={"MSTEAMS": {"channel": "test", "enabled": True}},
|
||||
)
|
||||
_assert(org, arc)
|
||||
|
||||
make_alert_receive_channel(org1, integration=AlertReceiveChannel.INTEGRATION_ALERTMANAGER)
|
||||
make_alert_receive_channel(org2, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING)
|
||||
|
||||
org1_direct_paging_integrations = org1.get_direct_paging_integrations()
|
||||
org2_direct_paging_integrations = org2.get_direct_paging_integrations()
|
||||
|
||||
assert len(org1_direct_paging_integrations) == 2
|
||||
assert len(org2_direct_paging_integrations) == 1
|
||||
|
||||
assert org1_direct_paging_integration1 in org1_direct_paging_integrations
|
||||
assert org1_direct_paging_integration2 in org1_direct_paging_integrations
|
||||
# integration's default channel filter has an escalation chain attached to it
|
||||
org, arc = _make_org_and_arc()
|
||||
escalation_chain = make_escalation_chain(org)
|
||||
make_channel_filter(arc, is_default=True, notify_in_slack=False, escalation_chain=escalation_chain)
|
||||
_assert(org, arc)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Including another URLconf
|
|||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import URLPattern, URLResolver, include, path
|
||||
|
||||
from .views import HealthCheckView, MaintenanceModeStatusView, ReadinessCheckView, StartupProbeView
|
||||
|
|
@ -76,7 +77,11 @@ if settings.DEBUG:
|
|||
] + urlpatterns
|
||||
|
||||
if settings.SILK_PROFILER_ENABLED:
|
||||
urlpatterns += [path(settings.SILK_PATH, include("silk.urls", namespace="silk"))]
|
||||
urlpatterns += [
|
||||
# need django admin enabled to be able to access silk
|
||||
path(settings.ONCALL_DJANGO_ADMIN_PATH, admin.site.urls),
|
||||
path(settings.SILK_PATH, include("silk.urls", namespace="silk")),
|
||||
]
|
||||
|
||||
if settings.DRF_SPECTACULAR_ENABLED:
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from common.utils import getenv_boolean, getenv_integer, getenv_list
|
|||
|
||||
VERSION = "dev-oss"
|
||||
SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True)
|
||||
ADMIN_ENABLED = False # disable django admin panel
|
||||
|
||||
# License is OpenSource or Cloud
|
||||
OPEN_SOURCE_LICENSE_NAME = "OpenSource"
|
||||
|
|
@ -590,6 +589,10 @@ SELF_IP = os.environ.get("SELF_IP")
|
|||
|
||||
SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False) and not IS_IN_MAINTENANCE_MODE
|
||||
|
||||
# django admin panel is required to auth with django silk. Otherwise if silk isn't enabled, we don't need it.
|
||||
ONCALL_DJANGO_ADMIN_PATH = os.environ.get("ONCALL_DJANGO_ADMIN_PATH", "django-admin") + "/"
|
||||
ADMIN_ENABLED = SILK_PROFILER_ENABLED
|
||||
|
||||
if SILK_PROFILER_ENABLED:
|
||||
SILK_PATH = os.environ.get("SILK_PATH", "silk/")
|
||||
SILKY_INTERCEPT_PERCENT = getenv_integer("SILKY_INTERCEPT_PERCENT", 100)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import Text from 'components/Text/Text';
|
|||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { Alert as AlertType } from 'models/alertgroup/alertgroup.types';
|
||||
import { getTimezone } from 'models/user/user.helpers';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserCurrentlyOnCall } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ const AddResponders = observer(
|
|||
const currentMoment = useMemo(() => dayjs(), []);
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
const [currentlyConsideredUser, setCurrentlyConsideredUser] = useState<User>(null);
|
||||
const [currentlyConsideredUser, setCurrentlyConsideredUser] = useState<UserCurrentlyOnCall>(null);
|
||||
const [currentlyConsideredUserNotificationPolicy, setCurrentlyConsideredUserNotificationPolicy] =
|
||||
useState<NotificationPolicyValue>(NotificationPolicyValue.Default);
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ const AddResponders = observer(
|
|||
disableNotificationPolicySelect
|
||||
handleDelete={generateRemovePreviouslyPagedUserCallback(user.pk)}
|
||||
important={user.important}
|
||||
data={user as unknown as User}
|
||||
data={user as unknown as UserCurrentlyOnCall}
|
||||
/>
|
||||
))}
|
||||
{selectedUserResponders.map((responder, index) => (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SelectableValue } from '@grafana/data';
|
||||
import { ActionMeta } from '@grafana/ui';
|
||||
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserCurrentlyOnCall } from 'models/user/user.types';
|
||||
|
||||
export enum NotificationPolicyValue {
|
||||
Default = 0,
|
||||
|
|
@ -9,7 +9,7 @@ export enum NotificationPolicyValue {
|
|||
}
|
||||
|
||||
export type UserResponder = {
|
||||
data: User;
|
||||
data: UserCurrentlyOnCall;
|
||||
important: boolean;
|
||||
};
|
||||
export type UserResponders = UserResponder[];
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
margin: 8px;
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.table {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('AddRespondersPopup', () => {
|
|||
},
|
||||
];
|
||||
|
||||
test('it renders teams properly', () => {
|
||||
test('it shows a loading message initially', () => {
|
||||
const mockStoreValue = {
|
||||
directPagingStore: {
|
||||
selectedTeamResponder: null,
|
||||
|
|
@ -30,36 +30,7 @@ describe('AddRespondersPopup', () => {
|
|||
getSearchResult: jest.fn().mockReturnValue(teams),
|
||||
},
|
||||
userStore: {
|
||||
getSearchResult: jest.fn().mockReturnValue({ results: [] }),
|
||||
},
|
||||
};
|
||||
|
||||
const component = render(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddRespondersPopup
|
||||
mode="create"
|
||||
visible={true}
|
||||
setVisible={jest.fn()}
|
||||
setCurrentlyConsideredUser={jest.fn()}
|
||||
setShowUserConfirmationModal={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('if a team is selected it shows an info alert', () => {
|
||||
const mockStoreValue = {
|
||||
directPagingStore: {
|
||||
selectedTeamResponder: teams[0],
|
||||
selectedUserResponders: [],
|
||||
},
|
||||
grafanaTeamStore: {
|
||||
getSearchResult: jest.fn().mockReturnValue(teams),
|
||||
},
|
||||
userStore: {
|
||||
getSearchResult: jest.fn().mockReturnValue({ results: [] }),
|
||||
search: jest.fn().mockReturnValue({ results: [] }),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useCallback, useEffect, useRef, FC } from 'react';
|
||||
|
||||
import { Alert, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import { Alert, HorizontalGroup, Icon, Input, LoadingPlaceholder, RadioButtonGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import { ColumnsType } from 'rc-table/lib/interface';
|
||||
|
|
@ -10,7 +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 { User } from 'models/user/user.types';
|
||||
import { UserCurrentlyOnCall } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks';
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ type Props = {
|
|||
visible: boolean;
|
||||
setVisible: (value: boolean) => void;
|
||||
|
||||
setCurrentlyConsideredUser: (user: User) => void;
|
||||
setCurrentlyConsideredUser: (user: UserCurrentlyOnCall) => void;
|
||||
setShowUserConfirmationModal: (value: boolean) => void;
|
||||
|
||||
existingPagedUsers?: AlertType['paged_users'];
|
||||
|
|
@ -34,13 +34,6 @@ enum TabOptions {
|
|||
Users = 'users',
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: properly filter out 'No team'. Right now it shows up on first render and then shortly thereafter the component
|
||||
* re-renders with 'No team' filtered out
|
||||
*
|
||||
* TODO: properly fetch/show loading state when fetching users. Right now it shows an empty list on the initial network
|
||||
* request, we can probably have a better experience here
|
||||
*/
|
||||
const AddRespondersPopup = observer(
|
||||
({
|
||||
mode,
|
||||
|
|
@ -55,23 +48,13 @@ const AddRespondersPopup = observer(
|
|||
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
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 [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const ref = useRef();
|
||||
const teamSearchResults = grafanaTeamStore.getSearchResult();
|
||||
|
||||
let userSearchResults = userStore.getSearchResult().results || [];
|
||||
|
||||
/**
|
||||
* in the context where some user(s) have already been paged (ex. on a direct paging generated
|
||||
* alert group detail page), we should filter out the search results to not include these users
|
||||
*/
|
||||
if (existingPagedUsers.length > 0) {
|
||||
const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk);
|
||||
userSearchResults = userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk));
|
||||
}
|
||||
|
||||
const usersCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => is_currently_oncall);
|
||||
const usersNotCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => !is_currently_oncall);
|
||||
|
||||
|
|
@ -87,7 +70,7 @@ const AddRespondersPopup = observer(
|
|||
);
|
||||
|
||||
const onClickUser = useCallback(
|
||||
async (user: User) => {
|
||||
async (user: UserCurrentlyOnCall) => {
|
||||
if (isCreateMode && user.is_currently_oncall) {
|
||||
directPagingStore.addUserToSelectedUsers(user);
|
||||
} else {
|
||||
|
|
@ -113,18 +96,86 @@ const AddRespondersPopup = observer(
|
|||
[setVisible, directPagingStore, setActiveOption]
|
||||
);
|
||||
|
||||
const handleSearchTermChange = useDebouncedCallback(() => {
|
||||
const searchForUsers = useCallback(async () => {
|
||||
const userResults = await userStore.search<UserCurrentlyOnCall>({ searchTerm, is_currently_oncall: 'all' });
|
||||
setUserSearchResults(userResults.results);
|
||||
}, [searchTerm]);
|
||||
|
||||
const searchForTeams = useCallback(async () => {
|
||||
await grafanaTeamStore.updateItems(searchTerm, false, true, false);
|
||||
setTeamSearchResults(grafanaTeamStore.getSearchResult());
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleSearchTermChange = useDebouncedCallback(async () => {
|
||||
setSearchLoading(true);
|
||||
|
||||
if (isCreateMode && activeOption === TabOptions.Teams) {
|
||||
grafanaTeamStore.updateItems(searchTerm, false, true, false);
|
||||
await searchForTeams();
|
||||
} else {
|
||||
userStore.updateItems({ searchTerm, short: 'false' });
|
||||
await searchForUsers();
|
||||
}
|
||||
|
||||
setSearchLoading(false);
|
||||
}, 500);
|
||||
|
||||
useEffect(handleSearchTermChange, [searchTerm, activeOption]);
|
||||
const onChangeTab = useCallback(
|
||||
async (tab: TabOptions) => {
|
||||
/**
|
||||
* there's no need to trigger a new search request when the user changes tabs if they don't have a
|
||||
* search term
|
||||
*/
|
||||
if (searchTerm) {
|
||||
setSearchLoading(true);
|
||||
|
||||
if (activeOption === TabOptions.Teams) {
|
||||
await searchForTeams();
|
||||
} else {
|
||||
await searchForUsers();
|
||||
}
|
||||
|
||||
setSearchLoading(false);
|
||||
}
|
||||
|
||||
setActiveOption(tab);
|
||||
},
|
||||
[searchTerm]
|
||||
);
|
||||
|
||||
useEffect(handleSearchTermChange, [searchTerm]);
|
||||
|
||||
/**
|
||||
* in the context where some user(s) have already been paged (ex. on a direct paging generated
|
||||
* alert group detail page), we should filter out the search results to not include these users
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (existingPagedUsers.length > 0) {
|
||||
const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk);
|
||||
setUserSearchResults((userSearchResults) =>
|
||||
userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk))
|
||||
);
|
||||
}
|
||||
}, [existingPagedUsers]);
|
||||
|
||||
/**
|
||||
* pre-populate the users and teams search results so that when the user opens AddRespondersPopup it is already
|
||||
* populated with data (nicer UX)
|
||||
*/
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
/**
|
||||
* teams are not relevant when the component is rendered in "update" mode so we skip fetching teams here
|
||||
*/
|
||||
if (isCreateMode) {
|
||||
await searchForTeams();
|
||||
}
|
||||
|
||||
await searchForUsers();
|
||||
setSearchLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const userIsSelected = useCallback(
|
||||
(user: User) => selectedUserResponders.some((userResponder) => userResponder.data.pk === user.pk),
|
||||
(user: UserCurrentlyOnCall) => selectedUserResponders.some((userResponder) => userResponder.data.pk === user.pk),
|
||||
[selectedUserResponders]
|
||||
);
|
||||
|
||||
|
|
@ -155,11 +206,11 @@ const AddRespondersPopup = observer(
|
|||
},
|
||||
];
|
||||
|
||||
const userColumns: ColumnsType<User> = [
|
||||
const userColumns: ColumnsType<UserCurrentlyOnCall> = [
|
||||
// TODO: how to make the rows span full width properly?
|
||||
{
|
||||
width: 300,
|
||||
render: (user: User) => {
|
||||
render: (user: UserCurrentlyOnCall) => {
|
||||
const { avatar, name, username, teams } = user;
|
||||
const disabled = userIsSelected(user);
|
||||
|
||||
|
|
@ -170,6 +221,7 @@ const AddRespondersPopup = observer(
|
|||
<Avatar size="small" src={avatar} />
|
||||
<Text type={disabled ? 'disabled' : undefined}>{name || username}</Text>
|
||||
</HorizontalGroup>
|
||||
{/* TODO: we should add an elippsis and/or tooltip in the event that the user has a ton of teams */}
|
||||
{teams?.length > 0 && <Text type="secondary">{teams.map(({ name }) => name).join(', ')}</Text>}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
@ -179,18 +231,18 @@ const AddRespondersPopup = observer(
|
|||
},
|
||||
{
|
||||
width: 40,
|
||||
render: (user: User) => (userIsSelected(user) ? <Icon name="check" /> : null),
|
||||
render: (user: UserCurrentlyOnCall) => (userIsSelected(user) ? <Icon name="check" /> : null),
|
||||
key: 'Checked',
|
||||
},
|
||||
];
|
||||
|
||||
const UserResultsSection: FC<{ header: string; users: User[] }> = ({ header, users }) =>
|
||||
const UserResultsSection: FC<{ header: string; users: UserCurrentlyOnCall[] }> = ({ header, users }) =>
|
||||
users.length > 0 && (
|
||||
<>
|
||||
<Text type="secondary" className={cx('user-results-section-header')}>
|
||||
{header}
|
||||
</Text>
|
||||
<GTable<User>
|
||||
<GTable<UserCurrentlyOnCall>
|
||||
emptyText={users ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
columns={userColumns}
|
||||
|
|
@ -223,11 +275,12 @@ const AddRespondersPopup = observer(
|
|||
]}
|
||||
className={cx('radio-buttons')}
|
||||
value={activeOption}
|
||||
onChange={setActiveOption}
|
||||
onChange={onChangeTab}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{activeOption === TabOptions.Teams && (
|
||||
{searchLoading && <LoadingPlaceholder className={cx('loading-placeholder')} text="Loading..." />}
|
||||
{!searchLoading && activeOption === TabOptions.Teams && (
|
||||
<>
|
||||
{selectedTeamResponder ? (
|
||||
<Alert
|
||||
|
|
@ -272,7 +325,7 @@ const AddRespondersPopup = observer(
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{activeOption === TabOptions.Users && (
|
||||
{!searchLoading && activeOption === TabOptions.Users && (
|
||||
<>
|
||||
<UserResultsSection header="On-call now" users={usersCurrentlyOnCall} />
|
||||
<UserResultsSection header="Not on-call" users={usersNotCurrentlyOnCall} />
|
||||
|
|
|
|||
|
|
@ -1,119 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AddRespondersPopup if a team is selected it shows an info alert 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="add-responders-dropdown"
|
||||
data-testid="add-responders-popup"
|
||||
>
|
||||
<div
|
||||
class="css-11uftlx-input-wrapper responders-filters"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1mlczho-input-input"
|
||||
data-testid="add-responders-search-input"
|
||||
placeholder="Search"
|
||||
style="padding-right: 12px;"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
class="css-7y3u6k-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.71,20.29,18,16.61A9,9,0,1,0,16.61,18l3.68,3.68a1,1,0,0,0,1.42,0A1,1,0,0,0,21.71,20.29ZM11,18a7,7,0,1,1,7-7A7,7,0,0,1,11,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radio-buttons css-sv3u8u"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="css-8hl977"
|
||||
id="option-teams-radiogroup-2"
|
||||
name="radiogroup-2"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-teams-radiogroup-2"
|
||||
>
|
||||
Teams
|
||||
|
||||
</label>
|
||||
<input
|
||||
class="css-8hl977"
|
||||
id="option-users-radiogroup-2"
|
||||
name="radiogroup-2"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-users-radiogroup-2"
|
||||
>
|
||||
Users
|
||||
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
aria-label="You can add only one team per escalation. Please remove the existing team before adding a new one."
|
||||
class="css-j2xd7x"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
class="css-38nxtd"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
data-name="Layer 1"
|
||||
height="24"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Zm0-8.5a1,1,0,0,0-1,1v3a1,1,0,0,0,2,0v-3A1,1,0,0,0,12,11.5Zm0-4a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-zmuccj"
|
||||
>
|
||||
<div
|
||||
class="css-hui7p1"
|
||||
>
|
||||
You can add only one team per escalation. Please remove the existing team before adding a new one.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddRespondersPopup it renders teams properly 1`] = `
|
||||
exports[`AddRespondersPopup it shows a loading message initially 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="add-responders-dropdown"
|
||||
|
|
@ -186,215 +73,17 @@ exports[`AddRespondersPopup it renders teams properly 1`] = `
|
|||
</label>
|
||||
</div>
|
||||
<div
|
||||
aria-label="[object Object]"
|
||||
class="css-j2xd7x team-direct-paging-info-alert"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
class="css-lq6a48 loading-placeholder"
|
||||
>
|
||||
Loading...
|
||||
|
||||
<div
|
||||
class="css-38nxtd"
|
||||
class="css-13pg8vy"
|
||||
data-testid="Spinner"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
data-name="Layer 1"
|
||||
height="24"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Zm0-8.5a1,1,0,0,0-1,1v3a1,1,0,0,0,2,0v-3A1,1,0,0,0,12,11.5Zm0-4a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-zmuccj"
|
||||
>
|
||||
<div
|
||||
class="css-hui7p1"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
You can only page teams which have a Direct Paging integration that is configured.
|
||||
|
||||
<a
|
||||
class="learn-more-link"
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
Learn more
|
||||
</div>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="root"
|
||||
data-testid="test__gTable"
|
||||
>
|
||||
<div
|
||||
class="rc-table filter-table table"
|
||||
>
|
||||
<div
|
||||
class="rc-table-container"
|
||||
>
|
||||
<div
|
||||
class="rc-table-content"
|
||||
>
|
||||
<table
|
||||
style="table-layout: auto;"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</colgroup>
|
||||
<tbody
|
||||
class="rc-table-tbody"
|
||||
>
|
||||
<tr
|
||||
class="rc-table-row rc-table-row-level-0"
|
||||
>
|
||||
<td
|
||||
class="rc-table-cell"
|
||||
>
|
||||
<div
|
||||
class="responder-item"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-small"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
1
|
||||
user
|
||||
|
||||
on-call
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
class="rc-table-row rc-table-row-level-0"
|
||||
>
|
||||
<td
|
||||
class="rc-table-cell"
|
||||
>
|
||||
<div
|
||||
class="responder-item"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-small"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
my test team 2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<i
|
||||
class="fa fa-spinner fa-spin fa-spin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserCurrentlyOnCall } from 'models/user/user.types';
|
||||
|
||||
import UserResponder from './UserResponder';
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ describe('UserResponder', () => {
|
|||
const user = {
|
||||
avatar: 'http://avatar.com/',
|
||||
username: 'johnsmith',
|
||||
} as User;
|
||||
} as UserCurrentlyOnCall;
|
||||
|
||||
test('it renders data properly', () => {
|
||||
const component = render(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Channel } from 'models/channel';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { PagedUser, User } from 'models/user/user.types';
|
||||
|
||||
export enum IncidentStatus {
|
||||
'Firing',
|
||||
|
|
@ -40,10 +40,6 @@ export interface GroupedAlert {
|
|||
render_for_web: RenderForWeb;
|
||||
}
|
||||
|
||||
export type PagedUser = Pick<User, 'pk' | 'name' | 'username' | 'avatar' | 'avatar_full'> & {
|
||||
important: boolean;
|
||||
};
|
||||
|
||||
export interface Alert {
|
||||
pk: string;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { UserResponders } from 'containers/AddResponders/AddResponders.types';
|
|||
import { Alert } from 'models/alertgroup/alertgroup.types';
|
||||
import BaseStore from 'models/base_store';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserCurrentlyOnCall } from 'models/user/user.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ export class DirectPagingStore extends BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
addUserToSelectedUsers = (user: User) => {
|
||||
addUserToSelectedUsers = (user: UserCurrentlyOnCall) => {
|
||||
this.selectedUserResponders = [
|
||||
...this.selectedUserResponders,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { pick } from 'lodash-es';
|
|||
|
||||
import { User } from './user.types';
|
||||
|
||||
export const getTimezone = (user: User) => {
|
||||
export const getTimezone = (user: Pick<User, 'timezone'>) => {
|
||||
return user.timezone || 'UTC';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
|||
import { getTimezone, prepareForUpdate } from './user.helpers';
|
||||
import { User } from './user.types';
|
||||
|
||||
type PaginatedUsersResponse<UT = User> = {
|
||||
count: number;
|
||||
page_size: number;
|
||||
results: UT[];
|
||||
};
|
||||
|
||||
export class UserStore extends BaseStore {
|
||||
@observable.shallow
|
||||
searchResult: { count?: number; results?: Array<User['pk']>; page_size?: number } = {};
|
||||
|
|
@ -110,13 +116,17 @@ export class UserStore extends BaseStore {
|
|||
delete this.itemsCurrentlyUpdating[userPk];
|
||||
}
|
||||
|
||||
@action
|
||||
async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
|
||||
async search<UT = User>(f: any = { searchTerm: '' }, page = 1): Promise<PaginatedUsersResponse<UT>> {
|
||||
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
|
||||
const { searchTerm: search, ...restFilters } = filters;
|
||||
const response = await makeRequest(this.path, {
|
||||
return makeRequest<PaginatedUsersResponse<UT>>(this.path, {
|
||||
params: { search, page, ...restFilters },
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
|
||||
const response = await this.search(f, page);
|
||||
|
||||
if (invalidateFn && invalidateFn()) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,20 @@ export interface MessagingBackends {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
interface BaseUser {
|
||||
pk: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
avatar_full: string;
|
||||
}
|
||||
|
||||
export interface User extends BaseUser {
|
||||
slack_login: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
avatar_full: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
hide_phone_number: boolean;
|
||||
username: string;
|
||||
slack_id: string;
|
||||
phone_verified: boolean;
|
||||
telegram_configuration: {
|
||||
|
|
@ -43,6 +46,14 @@ export interface User {
|
|||
hidden_fields?: boolean;
|
||||
timezone: Timezone;
|
||||
working_hours: { [key: string]: [] };
|
||||
is_currently_oncall?: boolean;
|
||||
teams?: GrafanaTeam[];
|
||||
}
|
||||
|
||||
export interface PagedUser extends BaseUser {
|
||||
important: boolean;
|
||||
}
|
||||
|
||||
export interface UserCurrentlyOnCall extends BaseUser {
|
||||
timezone: Timezone;
|
||||
is_currently_oncall: boolean;
|
||||
teams: GrafanaTeam[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue