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:
Joey Orlando 2023-11-03 12:40:54 -04:00 committed by GitHub
parent fb3bc0d7e5
commit 2cbb20601e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 428 additions and 585 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,10 @@
margin: 8px;
}
.loading-placeholder {
margin: 8px;
}
.table {
max-height: 150px;
overflow: auto;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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