diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 8ee438b6b..d053e5fdf 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -2413,3 +2413,44 @@ def test_filter_default_started_at( ) assert response.status_code == status.HTTP_200_OK assert response.json()["pk"] == old_alert_group.public_primary_key + + +@pytest.mark.django_db +def test_alert_group_affected_services( + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + make_alert_group_label_association, +): + _, token, alert_groups = alert_group_internal_api_setup + resolved_ag, ack_ag, new_ag, silenced_ag = alert_groups + organization = new_ag.channel.organization + user = make_user_for_organization(organization) + + # set firing alert group service label + make_alert_group_label_association(organization, new_ag, key_name="service_name", value_name="service-a") + # set other service name labels for other alert groups + make_alert_group_label_association(organization, ack_ag, key_name="service_name", value_name="service-2") + make_alert_group_label_association(organization, resolved_ag, key_name="service_name", value_name="service-3") + make_alert_group_label_association(organization, silenced_ag, key_name="service_name", value_name="service-4") + + client = APIClient() + url = reverse("api-internal:alertgroup-filter-affected-services") + + url = f"{url}?service=service-1&service=service-2&service=service-3&service=service-a" + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + expected = [ + { + "name": "service-2", + "service_url": "a/grafana-slo-app/service/service-2", + "alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-2", + }, + { + "name": "service-a", + "service_url": "a/grafana-slo-app/service/service-a", + "alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-a", + }, + ] + assert response.json() == expected diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 117fb9ce9..eaa32dbfc 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -26,6 +26,7 @@ from apps.api.serializers.team import TeamSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.labels.utils import is_labels_feature_enabled from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Team, User @@ -283,6 +284,7 @@ class AlertGroupView( "bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], "preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST], "escalation_snapshot": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "filter_affected_services": [RBACPermission.Permissions.ALERT_GROUPS_READ], } queryset = AlertGroup.objects.none() # needed for drf-spectacular introspection @@ -299,9 +301,18 @@ def get_serializer_class(self): return super().get_serializer_class() - def get_queryset(self, ignore_filtering_by_available_teams=False): - # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. - + def _get_queryset( + self, + action=None, + ignore_filtering_by_available_teams=False, + team_values=None, + started_at=None, + label_query=None, + ): + # make base get_queryset reusable via params + if action is None: + # assume stats by default + action = "stats" alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter( organization_id=self.request.auth.organization.id ) @@ -310,7 +321,6 @@ def get_queryset(self, ignore_filtering_by_available_teams=False): # Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter. # This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation - team_values = self.request.query_params.getlist("team", []) if team_values: null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None 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): alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True)) queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids) - if self.action in ("list", "stats") and not self.request.query_params.get("started_at"): + if action in ("list", "stats") and not started_at: queryset = queryset.filter(started_at__gte=timezone.now() - timezone.timedelta(days=30)) - if self.action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX: + if action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX: # workaround related to MySQL "ORDER BY LIMIT Query Optimizer Bug" # read more: https://hackmysql.com/infamous-order-by-limit-query-optimizer-bug/ from django_mysql.models import add_QuerySetMixin @@ -333,18 +343,28 @@ def get_queryset(self, ignore_filtering_by_available_teams=False): queryset = queryset.force_index("alert_group_list_index") # Filter by labels. Since alert group labels are "static" filter by names, not IDs. - label_query = self.request.query_params.getlist("label", []) - kv_pairs = parse_label_query(label_query) - for key, value in kv_pairs: - # Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel - queryset = queryset.filter( - labels__organization=self.request.auth.organization, - labels__key_name=key, - labels__value_name=value, - ) + if label_query: + kv_pairs = parse_label_query(label_query) + for key, value in kv_pairs: + # Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel + queryset = queryset.filter( + labels__organization=self.request.auth.organization, + labels__key_name=key, + labels__value_name=value, + ) return queryset + def get_queryset(self, ignore_filtering_by_available_teams=False): + # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. + return self._get_queryset( + action=self.action, + ignore_filtering_by_available_teams=ignore_filtering_by_available_teams, + team_values=self.request.query_params.getlist("team", []), + started_at=self.request.query_params.get("started_at"), + label_query=self.request.query_params.getlist("label", []), + ) + def get_object(self): obj = super().get_object() obj = self.enrich([obj])[0] @@ -881,3 +901,46 @@ def escalation_snapshot(self, request, pk=None): escalation_snapshot = alert_group.escalation_snapshot result = AlertGroupEscalationSnapshotAPISerializer(escalation_snapshot).data if escalation_snapshot else {} return Response(result) + + @extend_schema( + responses=inline_serializer( + name="AffectedServices", + fields={ + "name": serializers.CharField(), + "service_url": serializers.CharField(), + "alert_groups_url": serializers.CharField(), + }, + many=True, + ) + ) + @action(methods=["get"], detail=False) + def filter_affected_services(self, request): + """Given a list of service names, return the ones that have active alerts.""" + organization = self.request.auth.organization + services = self.request.query_params.getlist("service", []) + url_builder = UIURLBuilder(organization) + affected_services = [] + days_to_check = 7 + for service_name in services: + is_affected = ( + self._get_queryset( + started_at=timezone.now() - timezone.timedelta(days=days_to_check), + label_query=[f"service_name:{service_name}"], + ) + .filter( + resolved=False, + silenced=False, + ) + .exists() + ) + if is_affected: + affected_services.append( + { + "name": service_name, + "service_url": url_builder.service_page(service_name), + "alert_groups_url": url_builder.alert_groups( + f"?status=0&status=1&started_at=now-{days_to_check}d_now&label=service_name:{service_name}" + ), + } + ) + return Response(affected_services) diff --git a/engine/apps/grafana_plugin/tests/test_ui_url_builder.py b/engine/apps/grafana_plugin/tests/test_ui_url_builder.py index dad968772..b55b1c4ca 100644 --- a/engine/apps/grafana_plugin/tests/test_ui_url_builder.py +++ b/engine/apps/grafana_plugin/tests/test_ui_url_builder.py @@ -103,3 +103,9 @@ def test_build_url_overriden_base_url(org_setup): @pytest.mark.django_db def test_build_url_works_for_irm_and_oncall_plugins(org_setup, is_grafana_irm_enabled, expected_url): assert UIURLBuilder(org_setup(is_grafana_irm_enabled)).alert_group_detail(ALERT_GROUP_ID) == expected_url + + +@pytest.mark.django_db +def test_build_url_service_detail_page(org_setup): + builder = UIURLBuilder(org_setup()) + assert builder.service_page("service-a") == f"{GRAFANA_URL}/a/{PluginID.SLO}/service/service-a" diff --git a/engine/apps/grafana_plugin/ui_url_builder.py b/engine/apps/grafana_plugin/ui_url_builder.py index e37f8e754..5b4d6e641 100644 --- a/engine/apps/grafana_plugin/ui_url_builder.py +++ b/engine/apps/grafana_plugin/ui_url_builder.py @@ -56,3 +56,6 @@ def settings(self, path_extra: str = "") -> str: def declare_incident(self, path_extra: str = "") -> str: return self._build_url("incidents/declare", path_extra, plugin_id=PluginID.INCIDENT) + + def service_page(self, service_name: str, path_extra: str = "") -> str: + return self._build_url(f"service/{service_name}", path_extra, plugin_id=PluginID.SLO) diff --git a/engine/common/constants/plugin_ids.py b/engine/common/constants/plugin_ids.py index 666c30e21..db6ff6a2f 100644 --- a/engine/common/constants/plugin_ids.py +++ b/engine/common/constants/plugin_ids.py @@ -5,3 +5,4 @@ class PluginID: INCIDENT = "grafana-incident-app" LABELS = "grafana-labels-app" ML = "grafana-ml-app" + SLO = "grafana-slo-app"