v1.3.53
This commit is contained in:
commit
3ccbfa6e84
57 changed files with 1043 additions and 638 deletions
|
|
@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## Unreleased
|
||||
|
||||
## v1.3.53 (2023-11-03)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix db migration for mobile app @Ferril ([#3260](https://github.com/grafana/oncall/pull/3260))
|
||||
|
||||
## v1.3.52 (2023-11-02)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ x-environment: &oncall-environment
|
|||
PROMETHEUS_EXPORTER_SECRET: ${PROMETHEUS_EXPORTER_SECRET:-}
|
||||
REDIS_URI: redis://redis:6379/0
|
||||
DJANGO_SETTINGS_MODULE: settings.hobby
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery"
|
||||
CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery,grafana"
|
||||
CELERY_WORKER_CONCURRENCY: "1"
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD: "100"
|
||||
CELERY_WORKER_SHUTDOWN_INTERVAL: "65m"
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -45,8 +45,14 @@ def check_heartbeats() -> str:
|
|||
# * is enabled,
|
||||
# * is not already expired,
|
||||
# * last check in was before the timeout period start
|
||||
expired_heartbeats = enabled_heartbeats.select_for_update().filter(
|
||||
last_heartbeat_time__lte=F("period_start"), previous_alerted_state_was_life=True
|
||||
expired_heartbeats = (
|
||||
enabled_heartbeats.select_for_update()
|
||||
.filter(
|
||||
last_heartbeat_time__lte=F("period_start"),
|
||||
previous_alerted_state_was_life=True,
|
||||
alert_receive_channel__organization__deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("alert_receive_channel")
|
||||
)
|
||||
# Schedule alert creation for each expired heartbeat after transaction commit
|
||||
for heartbeat in expired_heartbeats:
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ def test_check_heartbeats(
|
|||
assert mock_create_alert_apply_async.call_count == 0
|
||||
|
||||
# Prepare heartbeat
|
||||
team, _ = make_organization_and_user()
|
||||
organization, _ = make_organization_and_user()
|
||||
timeout = 60
|
||||
last_heartbeat_time = timezone.now()
|
||||
alert_receive_channel = make_alert_receive_channel(team, integration=integration)
|
||||
alert_receive_channel = make_alert_receive_channel(organization, integration=integration)
|
||||
integration_heartbeat = make_integration_heartbeat(
|
||||
alert_receive_channel, timeout, last_heartbeat_time=last_heartbeat_time, previous_alerted_state_was_life=True
|
||||
)
|
||||
|
|
@ -78,3 +78,14 @@ def test_check_heartbeats(
|
|||
result = check_heartbeats()
|
||||
assert result == "Found 0 expired and 0 restored heartbeats"
|
||||
assert mock_create_alert_apply_async.call_count == 0
|
||||
|
||||
# Hearbeat expires, but organization was deleted, don't send an alert
|
||||
integration_heartbeat.refresh_from_db()
|
||||
integration_heartbeat.last_heartbeat_time = timezone.now() - timezone.timedelta(seconds=timeout * 10)
|
||||
integration_heartbeat.save()
|
||||
organization.delete()
|
||||
with patch.object(create_alert, "apply_async") as mock_create_alert_apply_async:
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
result = check_heartbeats()
|
||||
assert result == "Found 0 expired and 0 restored heartbeats"
|
||||
assert mock_create_alert_apply_async.call_count == 0
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@ import apps.mobile_app.models
|
|||
import django_migration_linter as linter
|
||||
|
||||
from django.db import migrations, models
|
||||
from apps.mobile_app.models import default_notification_timing_options
|
||||
|
||||
|
||||
def set_going_oncall_notification_timing_to_default(apps, schema_editor):
|
||||
MobileAppUserSettings = apps.get_model("mobile_app", "MobileAppUserSettings")
|
||||
default = default_notification_timing_options()
|
||||
MobileAppUserSettings.objects.all().update(going_oncall_notification_timing=default)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
@ -21,10 +14,13 @@ class Migration(migrations.Migration):
|
|||
|
||||
operations = [
|
||||
linter.IgnoreMigration(),
|
||||
migrations.AlterField(
|
||||
migrations.RemoveField(
|
||||
model_name='mobileappusersettings',
|
||||
name='going_oncall_notification_timing',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mobileappusersettings',
|
||||
name='going_oncall_notification_timing',
|
||||
field=models.JSONField(default=apps.mobile_app.models.default_notification_timing_options),
|
||||
),
|
||||
migrations.RunPython(set_going_oncall_notification_timing_to_default, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -6,8 +6,13 @@ from apps.telegram.renderers.keyboard import Action
|
|||
from apps.telegram.updates.update_handlers.button_press import ButtonPressHandler
|
||||
|
||||
|
||||
@patch(
|
||||
"apps.telegram.updates.update_handlers.button_press.ButtonPressHandler._get_alert_group_from_message",
|
||||
return_value=None,
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_get_action_context(
|
||||
mocked_get_alert_group_from_message,
|
||||
make_organization_and_user_with_slack_identities,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from typing import Callable, Optional, Tuple
|
|||
from apps.alerts.constants import ActionSource
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.api.permissions import RBACPermission, user_is_authorized
|
||||
from apps.telegram.models import TelegramToUserConnector
|
||||
from apps.telegram.models import TelegramMessage, TelegramToUserConnector
|
||||
from apps.telegram.renderers.keyboard import CODE_TO_ACTION_MAP, Action
|
||||
from apps.telegram.updates.update_handlers import UpdateHandler
|
||||
from apps.telegram.utils import CallbackQueryFactory
|
||||
|
|
@ -45,6 +45,15 @@ class ButtonPressHandler(UpdateHandler):
|
|||
self.update.callback_query.answer(PERMISSION_DENIED, show_alert=True)
|
||||
logger.info(f"User {user} has no permission to trigger '{fn.__name__}'")
|
||||
|
||||
def _get_alert_group_from_message(self) -> Optional[AlertGroup]:
|
||||
alert_group = None
|
||||
if self.update.message:
|
||||
telegram_message = TelegramMessage.objects.get(
|
||||
message_id=self.update.message.message_id, chat_id=self.update.message.chat.id
|
||||
)
|
||||
alert_group = telegram_message.alert_group
|
||||
return alert_group
|
||||
|
||||
def _get_user(self, action_context: ActionContext) -> Optional[User]:
|
||||
connector = TelegramToUserConnector.objects.filter(
|
||||
telegram_chat_id=self.update.effective_user.id,
|
||||
|
|
@ -60,12 +69,15 @@ class ButtonPressHandler(UpdateHandler):
|
|||
has_permission = user_is_authorized(user, [RBACPermission.Permissions.CHATOPS_WRITE])
|
||||
return user.organization == alert_group.channel.organization and has_permission
|
||||
|
||||
@classmethod
|
||||
def _get_action_context(cls, data: str) -> ActionContext:
|
||||
def _get_action_context(self, data: str) -> ActionContext:
|
||||
args = CallbackQueryFactory.decode_data(data)
|
||||
|
||||
alert_group_pk = args[0]
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
|
||||
# Try to get alert group from telegram message, because encoded data is not valid for migrated organizations
|
||||
alert_group = self._get_alert_group_from_message()
|
||||
|
||||
if alert_group is None:
|
||||
alert_group_pk = args[0]
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
|
||||
|
||||
action_value = args[1]
|
||||
try:
|
||||
|
|
@ -77,7 +89,7 @@ class ButtonPressHandler(UpdateHandler):
|
|||
action_name = action_value
|
||||
action = Action(action_name)
|
||||
|
||||
action_data = args[2] if len(args) >= 3 and not cls._is_oncall_identifier(args[2]) else None
|
||||
action_data = args[2] if len(args) >= 3 and not self._is_oncall_identifier(args[2]) else None
|
||||
|
||||
return ActionContext(alert_group=alert_group, action=action, action_data=action_data)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ def make_request(webhook, alert_group, data):
|
|||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 3
|
||||
)
|
||||
def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id):
|
||||
from apps.webhooks.models import Webhook
|
||||
|
|
|
|||
|
|
@ -82,4 +82,4 @@ class FifteenPageSizePaginator(PathPrefixedPagePagination):
|
|||
|
||||
class TwentyFiveCursorPaginator(PathPrefixedCursorPagination):
|
||||
page_size = 25
|
||||
ordering = "-pk"
|
||||
ordering = "-started_at"
|
||||
|
|
|
|||
42
engine/engine/tests/test_views.py
Normal file
42
engine/engine/tests/test_views.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import sys
|
||||
from importlib import import_module, reload
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.urls import clear_url_caches
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
def reload_urlconf():
|
||||
clear_url_caches()
|
||||
if settings.ROOT_URLCONF in sys.modules:
|
||||
reload(sys.modules[settings.ROOT_URLCONF])
|
||||
return import_module(settings.ROOT_URLCONF)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="TODO: This test is currently failing in oncall-private, skipping to unblock release")
|
||||
@pytest.mark.parametrize(
|
||||
"detached_integrations,urlconf,is_cache_updated",
|
||||
[
|
||||
(False, None, True),
|
||||
(True, None, False),
|
||||
(True, "engine.integrations_urls", True),
|
||||
],
|
||||
)
|
||||
def test_startupprobe_populates_integrations_cache(settings, detached_integrations, urlconf, is_cache_updated):
|
||||
settings.DETACHED_INTEGRATIONS_SERVER = detached_integrations
|
||||
if urlconf:
|
||||
settings.ROOT_URLCONF = urlconf
|
||||
reload_urlconf()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
with patch(
|
||||
"apps.integrations.mixins.AlertChannelDefiningMixin.update_alert_receive_channel_cache"
|
||||
) as mock_update_cache:
|
||||
response = client.get("/startupprobe/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert mock_update_cache.called == is_cache_updated
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from django import urls
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
|
|
@ -43,7 +44,13 @@ class StartupProbeView(View):
|
|||
dangerously_bypass_middlewares = True
|
||||
|
||||
def get(self, request):
|
||||
if cache.get(AlertChannelDefiningMixin.CACHE_KEY_DB_FALLBACK) is None:
|
||||
# enable integrations cache if current engine instance is serving them
|
||||
integrations_enabled = True
|
||||
if settings.DETACHED_INTEGRATIONS_SERVER:
|
||||
url_resolver = urls.get_resolver(urls.get_urlconf())
|
||||
integrations_enabled = url_resolver.namespace_dict.get("integrations")
|
||||
|
||||
if integrations_enabled and cache.get(AlertChannelDefiningMixin.CACHE_KEY_DB_FALLBACK) is None:
|
||||
AlertChannelDefiningMixin().update_alert_receive_channel_cache()
|
||||
|
||||
cache.set("healthcheck", "healthcheck", 30) # Checking cache connectivity
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
django==4.2.6
|
||||
django==4.2.7
|
||||
djangorestframework==3.14.0
|
||||
slack_sdk==3.21.3
|
||||
whitenoise==5.3.0
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
1. Create the cluster with [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
|
||||
> Make sure ports 30001 and 30002 are free on your machine
|
||||
> Make sure ports 30001, 30002 (Grafana, optional) and
|
||||
> 30003 (detached integrations server, optional) are free on your machine
|
||||
|
||||
```bash
|
||||
kind create cluster --image kindest/node:v1.24.7 --config kind.yml
|
||||
|
|
@ -10,12 +11,25 @@
|
|||
|
||||
2. (Optional) Build oncall image locally and load it to kind cluster
|
||||
|
||||
3. ```bash
|
||||
docker build ../engine -t oncall/engine:latest --target dev
|
||||
kind load docker-image oncall/engine:latest
|
||||
```bash
|
||||
docker build ../engine -t oncall/engine:latest --target dev
|
||||
kind load docker-image oncall/engine:latest
|
||||
```
|
||||
|
||||
4. Install the helm chart
|
||||
Also make sure to add the following lines to your `simple.yml` (you may also need to enable `devMode`):
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: oncall/engine
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
oncall:
|
||||
devMode: true
|
||||
```
|
||||
|
||||
Alternatively you can also pass an extra `--values ./local_image.yml` in the command below.
|
||||
|
||||
3. Install the helm chart
|
||||
|
||||
```bash
|
||||
helm install helm-testing \
|
||||
|
|
@ -24,14 +38,14 @@
|
|||
./oncall
|
||||
```
|
||||
|
||||
5. Get credentials
|
||||
4. Get credentials
|
||||
|
||||
```bash
|
||||
echo "\n\nOpen Grafana on localhost:30002 with credentials - user: admin, password: $(kubectl get secret --namespace default helm-testing-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo)"
|
||||
echo "Open Plugins -> Grafana OnCall -> fill form: backend url: http://host.docker.internal:30001"
|
||||
```
|
||||
|
||||
6. Clean up
|
||||
5. Clean up
|
||||
If you happen to `helm uninstall helm-testing` be sure to delete all the Persistent Volume Claims, as Postgres stores
|
||||
the auto-generated password on disk, and the next `helm install` will fail.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ nodes:
|
|||
hostPort: 30001
|
||||
- containerPort: 30002
|
||||
hostPort: 30002
|
||||
- containerPort: 30003
|
||||
hostPort: 30003
|
||||
# https://stackoverflow.com/a/62695918
|
||||
extraMounts:
|
||||
# this basically mounts our local ./grafana-plugin (frontend) directory into the kind node
|
||||
|
|
|
|||
6
helm/local_image.yml
Normal file
6
helm/local_image.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
image:
|
||||
repository: oncall/engine
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
oncall:
|
||||
devMode: true
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
value: "admin"
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: {{ .Values.detached_integrations.enabled | toString | title | quote }}
|
||||
{{- include "snippet.oncall.uwsgi" . }}
|
||||
- name: BROKER_TYPE
|
||||
value: {{ .Values.broker.type | default "rabbitmq" }}
|
||||
|
|
@ -640,3 +642,15 @@ when broker.type != rabbitmq, we do not need to include rabbitmq environment var
|
|||
value: {{ .Values.oncall.exporter.enabled | toString | title | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "snippet.oncall.engine.env" -}}
|
||||
{{ include "snippet.oncall.env" . }}
|
||||
{{ include "snippet.oncall.slack.env" . }}
|
||||
{{ include "snippet.oncall.telegram.env" . }}
|
||||
{{ include "snippet.oncall.smtp.env" . }}
|
||||
{{ include "snippet.oncall.twilio.env" . }}
|
||||
{{ include "snippet.oncall.exporter.env" . }}
|
||||
{{ include "snippet.db.env" . }}
|
||||
{{ include "snippet.broker.env" . }}
|
||||
{{ include "oncall.extraEnvs" . }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ Create the name of the service account to use
|
|||
{{- printf "%s-%s" .Release.Name "redis" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Generate engine image name */}}
|
||||
{{- define "oncall.engine.image" -}}
|
||||
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "oncall.initContainer" }}
|
||||
- name: wait-for-db
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ spec:
|
|||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
image: {{ include "oncall.engine.image" . }}
|
||||
{{- if .Values.oncall.devMode }}
|
||||
command: ["python", "manage.py", "start_celery"]
|
||||
{{- else }}
|
||||
|
|
@ -60,14 +60,7 @@ spec:
|
|||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- include "snippet.celery.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.slack.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.exporter.env" . | nindent 12 }}
|
||||
{{- include "snippet.db.env" . | nindent 12 }}
|
||||
{{- include "snippet.broker.env" . | nindent 12 }}
|
||||
{{- include "oncall.extraEnvs" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.engine.env" . | nindent 12 }}
|
||||
{{- if .Values.celery.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
exec:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ spec:
|
|||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
image: {{ include "oncall.engine.image" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- if .Values.oncall.devMode }}
|
||||
command: ["sh", "-c", "uwsgi --disable-logging --py-autoreload 3 --ini uwsgi.ini"]
|
||||
|
|
@ -44,15 +44,7 @@ spec:
|
|||
containerPort: 8080
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- include "snippet.oncall.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.slack.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.twilio.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.exporter.env" . | nindent 12 }}
|
||||
{{- include "snippet.db.env" . | nindent 12 }}
|
||||
{{- include "snippet.broker.env" . | nindent 12 }}
|
||||
{{- include "oncall.extraEnvs" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.engine.env" . | nindent 12 }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ spec:
|
|||
- name: {{ .Chart.Name }}-migrate
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
image: {{ include "oncall.engine.image" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- /bin/sh
|
||||
|
|
|
|||
|
|
@ -53,4 +53,13 @@ spec:
|
|||
port:
|
||||
number: 80
|
||||
{{- end }}
|
||||
{{ if .Values.detached_integrations.enabled }}
|
||||
- path: /integrations
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "oncall.detached_integrations.fullname" . }}
|
||||
port:
|
||||
number: 8080
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
26
helm/oncall/templates/integrations/_helpers.tpl
Normal file
26
helm/oncall/templates/integrations/_helpers.tpl
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{{/*
|
||||
Maximum of 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
*/}}
|
||||
{{- define "oncall.detached_integrations.name" -}}
|
||||
{{ include "oncall.name" . | trunc 55 }}-integrations
|
||||
{{- end }}
|
||||
|
||||
{{- define "oncall.detached_integrations.fullname" -}}
|
||||
{{ include "oncall.fullname" . | trunc 55 }}-integrations
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Integrations common labels
|
||||
*/}}
|
||||
{{- define "oncall.detached_integrations.labels" -}}
|
||||
{{ include "oncall.labels" . }}
|
||||
app.kubernetes.io/component: integrations
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Integrations selector labels
|
||||
*/}}
|
||||
{{- define "oncall.detached_integrations.selectorLabels" -}}
|
||||
{{ include "oncall.selectorLabels" . }}
|
||||
app.kubernetes.io/component: integrations
|
||||
{{- end }}
|
||||
99
helm/oncall/templates/integrations/deployment.yaml
Normal file
99
helm/oncall/templates/integrations/deployment.yaml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{{- if .Values.detached_integrations.enabled -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "oncall.detached_integrations.fullname" . }}
|
||||
labels:
|
||||
{{- include "oncall.detached_integrations.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.detached_integrations.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "oncall.detached_integrations.selectorLabels" . | nindent 6 }}
|
||||
strategy:
|
||||
{{- toYaml .Values.detached_integrations.updateStrategy | nindent 4 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
random-annotation: {{ randAlphaNum 10 | lower }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "oncall.detached_integrations.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "oncall.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
initContainers:
|
||||
{{- include "oncall.initContainer" . | indent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: {{ include "oncall.engine.image" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- if .Values.oncall.devMode }}
|
||||
command: ["sh", "-c", "uwsgi --disable-logging --py-autoreload 3 --ini uwsgi.ini"]
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- include "snippet.oncall.engine.env" . | nindent 12 }}
|
||||
- name: ROOT_URLCONF
|
||||
value: "engine.integrations_urls"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready/
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /startupprobe/
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
resources:
|
||||
{{- toYaml .Values.detached_integrations.resources | nindent 12 }}
|
||||
{{- with .Values.detached_integrations.extraVolumeMounts }}
|
||||
volumeMounts: {{- . | toYaml | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.extraContainers }}
|
||||
{{- tpl . $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.priorityClassName }}
|
||||
priorityClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.detached_integrations.extraVolumes }}
|
||||
volumes: {{- . | toYaml | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
24
helm/oncall/templates/integrations/service-external.yaml
Normal file
24
helm/oncall/templates/integrations/service-external.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{{- if .Values.detached_integrations_service.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "oncall.detached_integrations.fullname" . }}-external
|
||||
labels:
|
||||
{{- include "oncall.detached_integrations.labels" . | nindent 4 }}
|
||||
{{- with .Values.detached_integrations_service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.detached_integrations_service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.detached_integrations_service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
{{- if and (eq .Values.detached_integrations_service.type "NodePort") (.Values.detached_integrations_service.nodePort) }}
|
||||
nodePort: {{ .Values.detached_integrations_service.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "oncall.detached_integrations.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
15
helm/oncall/templates/integrations/service-internal.yaml
Normal file
15
helm/oncall/templates/integrations/service-internal.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "oncall.detached_integrations.fullname" . }}
|
||||
labels:
|
||||
{{- include "oncall.detached_integrations.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "oncall.detached_integrations.selectorLabels" . | nindent 4 }}
|
||||
|
|
@ -28,7 +28,7 @@ spec:
|
|||
- name: telegram-polling
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
image: {{ include "oncall.engine.image" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command: ['sh', '-c', 'python manage.py start_telegram_polling']
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
detached_integrations.enabled=true -> should create integrations deployment:
|
||||
1: |
|
||||
- env:
|
||||
- name: BASE_URL
|
||||
value: https://example.com
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: SECRET_KEY
|
||||
name: oncall
|
||||
- name: MIRAGE_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: MIRAGE_SECRET_KEY
|
||||
name: oncall
|
||||
- name: MIRAGE_CIPHER_IV
|
||||
value: 1234567890abcdef
|
||||
- name: DJANGO_SETTINGS_MODULE
|
||||
value: settings.helm
|
||||
- name: AMIXR_DJANGO_ADMIN_PATH
|
||||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "True"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
value: rabbitmq
|
||||
- name: GRAFANA_API_URL
|
||||
value: http://oncall-grafana
|
||||
- name: FEATURE_SLACK_INTEGRATION_ENABLED
|
||||
value: "False"
|
||||
- name: FEATURE_TELEGRAM_INTEGRATION_ENABLED
|
||||
value: "False"
|
||||
- name: FEATURE_EMAIL_INTEGRATION_ENABLED
|
||||
value: "True"
|
||||
- name: EMAIL_HOST
|
||||
value: null
|
||||
- name: EMAIL_PORT
|
||||
value: "587"
|
||||
- name: EMAIL_HOST_USER
|
||||
value: null
|
||||
- name: EMAIL_HOST_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: smtp-password
|
||||
name: oncall-smtp
|
||||
optional: true
|
||||
- name: EMAIL_USE_TLS
|
||||
value: "True"
|
||||
- name: EMAIL_FROM_ADDRESS
|
||||
value: null
|
||||
- name: EMAIL_NOTIFICATIONS_LIMIT
|
||||
value: "200"
|
||||
- name: FEATURE_PROMETHEUS_EXPORTER_ENABLED
|
||||
value: "False"
|
||||
- name: MYSQL_HOST
|
||||
value: oncall-mariadb
|
||||
- name: MYSQL_PORT
|
||||
value: "3306"
|
||||
- name: MYSQL_DB_NAME
|
||||
value: oncall
|
||||
- name: MYSQL_USER
|
||||
value: root
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: mariadb-root-password
|
||||
name: oncall-mariadb
|
||||
- name: REDIS_PROTOCOL
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: oncall-redis-master
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
- name: REDIS_DATABASE
|
||||
value: "0"
|
||||
- name: REDIS_USERNAME
|
||||
value: ""
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: redis-password
|
||||
name: oncall-redis
|
||||
- name: RABBITMQ_USERNAME
|
||||
value: user
|
||||
- name: RABBITMQ_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: rabbitmq-password
|
||||
name: oncall-rabbitmq
|
||||
- name: RABBITMQ_HOST
|
||||
value: oncall-rabbitmq
|
||||
- name: RABBITMQ_PORT
|
||||
value: "5672"
|
||||
- name: RABBITMQ_PROTOCOL
|
||||
value: amqp
|
||||
- name: RABBITMQ_VHOST
|
||||
value: ""
|
||||
- name: ROOT_URLCONF
|
||||
value: engine.integrations_urls
|
||||
image: grafana/oncall:v1.3.39
|
||||
imagePullPolicy: Always
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 3
|
||||
name: oncall
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready/
|
||||
port: http
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 3
|
||||
resources: {}
|
||||
securityContext: {}
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /startupprobe/
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
|
|
@ -25,6 +25,8 @@ telegramPolling.enabled=true -> should create telegram polling deployment:
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ database.type=mysql -> should create initContainer for MySQL database (default):
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
@ -111,6 +113,8 @@ database.type=mysql -> should create initContainer for MySQL database (default):
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
@ -197,6 +201,8 @@ database.type=mysql -> should create initContainer for MySQL database (default):
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
@ -284,6 +290,8 @@ database.type=postgresql -> should create initContainer for PostgreSQL database:
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
@ -372,6 +380,8 @@ database.type=postgresql -> should create initContainer for PostgreSQL database:
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
@ -460,6 +470,8 @@ database.type=postgresql -> should create initContainer for PostgreSQL database:
|
|||
value: admin
|
||||
- name: OSS
|
||||
value: "True"
|
||||
- name: DETACHED_INTEGRATIONS_SERVER
|
||||
value: "False"
|
||||
- name: UWSGI_LISTEN
|
||||
value: "1024"
|
||||
- name: BROKER_TYPE
|
||||
|
|
|
|||
52
helm/oncall/tests/integrations_deployment_test.yaml
Normal file
52
helm/oncall/tests/integrations_deployment_test.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
suite: test integrations deployment
|
||||
templates:
|
||||
- integrations/deployment.yaml
|
||||
release:
|
||||
name: oncall
|
||||
chart:
|
||||
appVersion: v1.3.39
|
||||
tests:
|
||||
- it: detached_integrations.enabled=false -> should not create deployment (default)
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
|
||||
- it: detached_integrations.enabled=true -> should create integrations deployment
|
||||
set:
|
||||
detached_integrations.enabled: true
|
||||
asserts:
|
||||
- containsDocument:
|
||||
kind: Deployment
|
||||
apiVersion: apps/v1
|
||||
metadata.name: oncall-integrations
|
||||
- isSubset:
|
||||
path: metadata.labels
|
||||
content:
|
||||
app.kubernetes.io/component: integrations
|
||||
app.kubernetes.io/instance: oncall
|
||||
app.kubernetes.io/name: oncall
|
||||
- isSubset:
|
||||
path: spec.selector.matchLabels
|
||||
content:
|
||||
app.kubernetes.io/component: integrations
|
||||
app.kubernetes.io/instance: oncall
|
||||
app.kubernetes.io/name: oncall
|
||||
- isSubset:
|
||||
path: spec.template.metadata.labels
|
||||
content:
|
||||
app.kubernetes.io/component: integrations
|
||||
app.kubernetes.io/instance: oncall
|
||||
app.kubernetes.io/name: oncall
|
||||
- equal:
|
||||
path: spec.replicas
|
||||
value: 1
|
||||
- equal:
|
||||
path: spec.template.spec.serviceAccountName
|
||||
value: oncall
|
||||
- contains:
|
||||
path: spec.template.spec.initContainers
|
||||
content:
|
||||
name: wait-for-db
|
||||
any: true
|
||||
- matchSnapshot:
|
||||
path: spec.template.spec.containers
|
||||
|
|
@ -95,6 +95,82 @@ engine:
|
|||
# - mountPath: /mnt/redis-tls
|
||||
# name: redis-tls
|
||||
|
||||
|
||||
detached_integrations_service:
|
||||
enabled: false
|
||||
type: LoadBalancer
|
||||
port: 8080
|
||||
annotations: {}
|
||||
|
||||
# Integrations pods configuration
|
||||
detached_integrations:
|
||||
enabled: false
|
||||
replicaCount: 1
|
||||
resources:
|
||||
{}
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
## Deployment update strategy
|
||||
## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
|
||||
updateStrategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 0
|
||||
type: RollingUpdate
|
||||
|
||||
## Affinity for pod assignment
|
||||
## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity
|
||||
affinity: {}
|
||||
|
||||
## Node labels for pod assignment
|
||||
## ref: https://kubernetes.io/docs/user-guide/node-selection/
|
||||
nodeSelector: {}
|
||||
|
||||
## Tolerations for pod assignment
|
||||
## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/
|
||||
tolerations: []
|
||||
|
||||
## Topology spread constraints for pod assignment
|
||||
## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
|
||||
topologySpreadConstraints: []
|
||||
|
||||
## Priority class for the pods
|
||||
## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
|
||||
priorityClassName: ""
|
||||
|
||||
# Extra containers which runs as sidecar
|
||||
extraContainers: ""
|
||||
# extraContainers: |
|
||||
# - name: cloud-sql-proxy
|
||||
# image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.2
|
||||
# args:
|
||||
# - --private-ip
|
||||
# - --port=5432
|
||||
# - example:europe-west3:grafana-oncall-db
|
||||
|
||||
# Extra volume mounts for the container
|
||||
extraVolumeMounts: []
|
||||
# - name: postgres-tls
|
||||
# configMap:
|
||||
# name: my-postgres-tls
|
||||
# defaultMode: 0640
|
||||
# - name: redis-tls
|
||||
# configMap:
|
||||
# name: my-redis-tls
|
||||
# defaultMode: 0640
|
||||
|
||||
# Extra volumes for the pod
|
||||
extraVolumes: []
|
||||
# - mountPath: /mnt/postgres-tls
|
||||
# name: postgres-tls
|
||||
# - mountPath: /mnt/redis-tls
|
||||
# name: redis-tls
|
||||
|
||||
# Celery workers pods configuration
|
||||
celery:
|
||||
replicaCount: 1
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ grafana:
|
|||
service:
|
||||
type: NodePort
|
||||
nodePort: 30002
|
||||
detached_integrations:
|
||||
enabled: true
|
||||
detached_integrations_service:
|
||||
enabled: true
|
||||
type: NodePort
|
||||
port: 8080
|
||||
nodePort: 30003
|
||||
database:
|
||||
# can be either mysql or postgresql
|
||||
type: postgresql
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue