Skip to content

Commit 2a87bea

Browse files
authored
feat: add filter affected services internal endpoint (#5415)
Related to https://github.com/grafana/oncall-private/issues/2977 e.g. `GET /api/plugins/grafana-oncall-app/resources/alertgroups/filter_affected_services?service=service-a&service=service-b` ``` [ {"name": "service-a", "service_url": "http://localhost:3000/a/grafana-slo-app/service/service-a", "alert_groups_url": "http://localhost:3000/a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-a"} ] ```
1 parent 4c92826 commit 2a87bea

File tree

5 files changed

+129
-15
lines changed

5 files changed

+129
-15
lines changed

engine/apps/api/tests/test_alert_group.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,3 +2413,44 @@ def test_filter_default_started_at(
24132413
)
24142414
assert response.status_code == status.HTTP_200_OK
24152415
assert response.json()["pk"] == old_alert_group.public_primary_key
2416+
2417+
2418+
@pytest.mark.django_db
2419+
def test_alert_group_affected_services(
2420+
alert_group_internal_api_setup,
2421+
make_user_for_organization,
2422+
make_user_auth_headers,
2423+
make_alert_group_label_association,
2424+
):
2425+
_, token, alert_groups = alert_group_internal_api_setup
2426+
resolved_ag, ack_ag, new_ag, silenced_ag = alert_groups
2427+
organization = new_ag.channel.organization
2428+
user = make_user_for_organization(organization)
2429+
2430+
# set firing alert group service label
2431+
make_alert_group_label_association(organization, new_ag, key_name="service_name", value_name="service-a")
2432+
# set other service name labels for other alert groups
2433+
make_alert_group_label_association(organization, ack_ag, key_name="service_name", value_name="service-2")
2434+
make_alert_group_label_association(organization, resolved_ag, key_name="service_name", value_name="service-3")
2435+
make_alert_group_label_association(organization, silenced_ag, key_name="service_name", value_name="service-4")
2436+
2437+
client = APIClient()
2438+
url = reverse("api-internal:alertgroup-filter-affected-services")
2439+
2440+
url = f"{url}?service=service-1&service=service-2&service=service-3&service=service-a"
2441+
response = client.get(url, format="json", **make_user_auth_headers(user, token))
2442+
2443+
assert response.status_code == status.HTTP_200_OK
2444+
expected = [
2445+
{
2446+
"name": "service-2",
2447+
"service_url": "a/grafana-slo-app/service/service-2",
2448+
"alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-2",
2449+
},
2450+
{
2451+
"name": "service-a",
2452+
"service_url": "a/grafana-slo-app/service/service-a",
2453+
"alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-a",
2454+
},
2455+
]
2456+
assert response.json() == expected

engine/apps/api/views/alert_group.py

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from apps.api.serializers.team import TeamSerializer
2727
from apps.auth_token.auth import PluginAuthentication
2828
from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord
29+
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
2930
from apps.labels.utils import is_labels_feature_enabled
3031
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
3132
from apps.user_management.models import Team, User
@@ -283,6 +284,7 @@ class AlertGroupView(
283284
"bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
284285
"preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST],
285286
"escalation_snapshot": [RBACPermission.Permissions.ALERT_GROUPS_READ],
287+
"filter_affected_services": [RBACPermission.Permissions.ALERT_GROUPS_READ],
286288
}
287289

288290
queryset = AlertGroup.objects.none() # needed for drf-spectacular introspection
@@ -299,9 +301,18 @@ def get_serializer_class(self):
299301

300302
return super().get_serializer_class()
301303

302-
def get_queryset(self, ignore_filtering_by_available_teams=False):
303-
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
304-
304+
def _get_queryset(
305+
self,
306+
action=None,
307+
ignore_filtering_by_available_teams=False,
308+
team_values=None,
309+
started_at=None,
310+
label_query=None,
311+
):
312+
# make base get_queryset reusable via params
313+
if action is None:
314+
# assume stats by default
315+
action = "stats"
305316
alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter(
306317
organization_id=self.request.auth.organization.id
307318
)
@@ -310,7 +321,6 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):
310321

311322
# Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter.
312323
# This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation
313-
team_values = self.request.query_params.getlist("team", [])
314324
if team_values:
315325
null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None
316326
teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE])
@@ -321,10 +331,10 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):
321331
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
322332
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)
323333

324-
if self.action in ("list", "stats") and not self.request.query_params.get("started_at"):
334+
if action in ("list", "stats") and not started_at:
325335
queryset = queryset.filter(started_at__gte=timezone.now() - timezone.timedelta(days=30))
326336

327-
if self.action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX:
337+
if action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX:
328338
# workaround related to MySQL "ORDER BY LIMIT Query Optimizer Bug"
329339
# read more: https://hackmysql.com/infamous-order-by-limit-query-optimizer-bug/
330340
from django_mysql.models import add_QuerySetMixin
@@ -333,18 +343,28 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):
333343
queryset = queryset.force_index("alert_group_list_index")
334344

335345
# Filter by labels. Since alert group labels are "static" filter by names, not IDs.
336-
label_query = self.request.query_params.getlist("label", [])
337-
kv_pairs = parse_label_query(label_query)
338-
for key, value in kv_pairs:
339-
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
340-
queryset = queryset.filter(
341-
labels__organization=self.request.auth.organization,
342-
labels__key_name=key,
343-
labels__value_name=value,
344-
)
346+
if label_query:
347+
kv_pairs = parse_label_query(label_query)
348+
for key, value in kv_pairs:
349+
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
350+
queryset = queryset.filter(
351+
labels__organization=self.request.auth.organization,
352+
labels__key_name=key,
353+
labels__value_name=value,
354+
)
345355

346356
return queryset
347357

358+
def get_queryset(self, ignore_filtering_by_available_teams=False):
359+
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
360+
return self._get_queryset(
361+
action=self.action,
362+
ignore_filtering_by_available_teams=ignore_filtering_by_available_teams,
363+
team_values=self.request.query_params.getlist("team", []),
364+
started_at=self.request.query_params.get("started_at"),
365+
label_query=self.request.query_params.getlist("label", []),
366+
)
367+
348368
def get_object(self):
349369
obj = super().get_object()
350370
obj = self.enrich([obj])[0]
@@ -881,3 +901,46 @@ def escalation_snapshot(self, request, pk=None):
881901
escalation_snapshot = alert_group.escalation_snapshot
882902
result = AlertGroupEscalationSnapshotAPISerializer(escalation_snapshot).data if escalation_snapshot else {}
883903
return Response(result)
904+
905+
@extend_schema(
906+
responses=inline_serializer(
907+
name="AffectedServices",
908+
fields={
909+
"name": serializers.CharField(),
910+
"service_url": serializers.CharField(),
911+
"alert_groups_url": serializers.CharField(),
912+
},
913+
many=True,
914+
)
915+
)
916+
@action(methods=["get"], detail=False)
917+
def filter_affected_services(self, request):
918+
"""Given a list of service names, return the ones that have active alerts."""
919+
organization = self.request.auth.organization
920+
services = self.request.query_params.getlist("service", [])
921+
url_builder = UIURLBuilder(organization)
922+
affected_services = []
923+
days_to_check = 7
924+
for service_name in services:
925+
is_affected = (
926+
self._get_queryset(
927+
started_at=timezone.now() - timezone.timedelta(days=days_to_check),
928+
label_query=[f"service_name:{service_name}"],
929+
)
930+
.filter(
931+
resolved=False,
932+
silenced=False,
933+
)
934+
.exists()
935+
)
936+
if is_affected:
937+
affected_services.append(
938+
{
939+
"name": service_name,
940+
"service_url": url_builder.service_page(service_name),
941+
"alert_groups_url": url_builder.alert_groups(
942+
f"?status=0&status=1&started_at=now-{days_to_check}d_now&label=service_name:{service_name}"
943+
),
944+
}
945+
)
946+
return Response(affected_services)

engine/apps/grafana_plugin/tests/test_ui_url_builder.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,9 @@ def test_build_url_overriden_base_url(org_setup):
103103
@pytest.mark.django_db
104104
def test_build_url_works_for_irm_and_oncall_plugins(org_setup, is_grafana_irm_enabled, expected_url):
105105
assert UIURLBuilder(org_setup(is_grafana_irm_enabled)).alert_group_detail(ALERT_GROUP_ID) == expected_url
106+
107+
108+
@pytest.mark.django_db
109+
def test_build_url_service_detail_page(org_setup):
110+
builder = UIURLBuilder(org_setup())
111+
assert builder.service_page("service-a") == f"{GRAFANA_URL}/a/{PluginID.SLO}/service/service-a"

engine/apps/grafana_plugin/ui_url_builder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ def settings(self, path_extra: str = "") -> str:
5656

5757
def declare_incident(self, path_extra: str = "") -> str:
5858
return self._build_url("incidents/declare", path_extra, plugin_id=PluginID.INCIDENT)
59+
60+
def service_page(self, service_name: str, path_extra: str = "") -> str:
61+
return self._build_url(f"service/{service_name}", path_extra, plugin_id=PluginID.SLO)

engine/common/constants/plugin_ids.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ class PluginID:
55
INCIDENT = "grafana-incident-app"
66
LABELS = "grafana-labels-app"
77
ML = "grafana-ml-app"
8+
SLO = "grafana-slo-app"

0 commit comments

Comments
 (0)