diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index ef36f75d..3dd1dd40 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -11,7 +11,7 @@ from typing import Any from uuid import UUID from fastapi import APIRouter, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from src.services.channel_event_dossier_service import ( fetch_channel_event_dossier, @@ -123,6 +123,10 @@ class ChannelEventRecurrenceSummary(BaseModel): duplicate_event_total: int linked_run_total: int unlinked_event_total: int + auto_repair_linked_total: int = 0 + verified_repair_group_total: int = 0 + open_work_item_group_total: int = 0 + manual_gate_group_total: int = 0 latest_received_at: datetime | None @@ -140,6 +144,10 @@ class ChannelEventRecurrenceItem(BaseModel): latest_run_id: UUID | None latest_run_state: str | None latest_agent_id: str | None + latest_incident_id: str | None = None + incident_ids: list[str] = Field(default_factory=list) + repair_summary: dict[str, Any] | None = None + work_item: dict[str, Any] | None = None occurrence_total: int duplicate_total: int linked_run_total: int @@ -172,7 +180,9 @@ class ChannelEventRecurrenceResponse(BaseModel): async def get_event_dossier( project_id: str | None = Query(None, description="租戶 ID(可選)"), run_id: UUID | None = Query(None, description="Run ID(可選)"), - provider_event_id: str | None = Query(None, description="provider_event_id(可選)"), + provider_event_id: str | None = Query( + None, description="provider_event_id(可選)" + ), limit: int = Query(20, ge=1, le=50, description="最多返回筆數"), ) -> dict[str, Any]: return await fetch_channel_event_dossier( @@ -194,7 +204,9 @@ async def get_event_dossier( ) async def get_event_dossier_coverage( project_id: str | None = Query(None, description="租戶 ID(可選)"), - provider: str | None = Query(None, description="provider(可選,如 sentry / signoz)"), + provider: str | None = Query( + None, description="provider(可選,如 sentry / signoz)" + ), limit: int = Query(100, ge=1, le=200, description="最多納入統計筆數"), ) -> dict[str, Any]: return await fetch_channel_event_dossier_coverage( @@ -215,7 +227,9 @@ async def get_event_dossier_coverage( ) async def get_event_dossier_recurrence( project_id: str | None = Query(None, description="租戶 ID(可選)"), - provider: str | None = Query(None, description="provider(可選,如 alertmanager / sentry / signoz)"), + provider: str | None = Query( + None, description="provider(可選,如 alertmanager / sentry / signoz)" + ), limit: int = Query(100, ge=1, le=300, description="最多納入統計筆數"), ) -> dict[str, Any]: return await fetch_channel_event_dossier_recurrence( @@ -237,7 +251,9 @@ async def get_event_dossier_recurrence( async def list_recent_events( project_id: str | None = Query(None, description="租戶 ID(可選)"), channel_type: str | None = Query(None, description="通道類型(可選)"), - provider_prefix: str | None = Query(None, description="provider_event_id 前綴(可選)"), + provider_prefix: str | None = Query( + None, description="provider_event_id 前綴(可選)" + ), limit: int = Query(20, ge=1, le=100, description="最多返回筆數"), ) -> dict[str, Any]: return await list_recent_channel_events( diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index b571586e..9cf9f1b0 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -5,6 +5,7 @@ from uuid import UUID import pytest from fastapi import HTTPException +from src.api.v1.platform.events import ChannelEventRecurrenceResponse from src.services import channel_event_dossier_service from src.services.channel_event_dossier_service import ( build_dossier_coverage, @@ -284,6 +285,72 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None: } +def test_recurrence_response_model_preserves_repair_work_item_fields() -> None: + response = ChannelEventRecurrenceResponse.model_validate( + { + "project_id": "awoooi", + "limit": 20, + "summary": { + "source_event_total": 1, + "recurrence_group_total": 1, + "recurrent_group_total": 1, + "duplicate_event_total": 0, + "linked_run_total": 1, + "unlinked_event_total": 0, + "auto_repair_linked_total": 1, + "verified_repair_group_total": 1, + "open_work_item_group_total": 0, + "manual_gate_group_total": 0, + "latest_received_at": "2026-05-13T13:47:00", + }, + "items": [ + { + "recurrence_key": "fingerprint:fp-host-disk", + "provider": "alertmanager", + "alertname": "HostDiskUsageHigh", + "severity": "warning", + "namespace": "node", + "target_resource": "host-110", + "fingerprint": "fp-host-disk", + "latest_event_id": "11111111-1111-4111-8111-111111111111", + "latest_provider_event_id": "alertmanager:received:1", + "latest_content_preview": "Host disk pressure", + "latest_run_id": "22222222-2222-4222-8222-222222222222", + "latest_run_state": "completed", + "latest_agent_id": "openclaw", + "latest_incident_id": "INC-20260513-ABCD", + "incident_ids": ["INC-20260513-ABCD"], + "repair_summary": { + "status": "auto_repair_verified", + "latest_auto_repair_id": "repair-1", + }, + "work_item": { + "work_item_id": "verification:INC-20260513-ABCD:repair-1", + "status": "closed", + }, + "occurrence_total": 1, + "duplicate_total": 0, + "linked_run_total": 1, + "source_ref_total": 3, + "missing_source_refs_total": 0, + "sentry_ref_total": 0, + "signoz_ref_total": 0, + "alert_ref_total": 1, + "run_state_counts": {"completed": 1}, + "first_received_at": "2026-05-13T13:47:00", + "latest_received_at": "2026-05-13T13:47:00", + } + ], + } + ) + + payload = response.model_dump() + assert payload["summary"]["auto_repair_linked_total"] == 1 + assert payload["items"][0]["latest_incident_id"] == "INC-20260513-ABCD" + assert payload["items"][0]["repair_summary"]["status"] == "auto_repair_verified" + assert payload["items"][0]["work_item"]["status"] == "closed" + + @pytest.mark.asyncio async def test_fetch_channel_event_dossier_requires_source() -> None: with pytest.raises(HTTPException) as exc_info: