diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 03bc1f20..2028ce49 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -98,7 +98,8 @@ class DecideApprovalResponse(BaseModel): response_model=ListRunsResponse, summary="列出 Runs", description=( - "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status / incident_id filter 與分頁。\n\n" + "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status / " + "callback_reply_status / incident_id filter 與分頁。\n\n" "- 按 created_at DESC 排序\n" "- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突" ), @@ -110,6 +111,10 @@ async def list_runs( None, description="AI 證據狀態 filter(no_evidence/mcp_observed/read_only_dry_run/write_observed/blocked/observed)", ), + callback_reply_status: str | None = Query( + None, + description="Telegram callback reply 狀態 filter(no_callback/sent/fallback_sent/rescue_sent/failed/observed)", + ), incident_id: str | None = Query(None, description="關聯 Incident ID filter(可選)"), page: int = Query(1, ge=1, description="頁碼,從 1 開始"), per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"), @@ -118,6 +123,7 @@ async def list_runs( project_id=project_id, state=state, remediation_status=remediation_status, + callback_reply_status=callback_reply_status, incident_id=incident_id, page=page, per_page=per_page, diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 6b8bd3a6..3b491f9f 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -57,6 +57,14 @@ _REMEDIATION_STATUS_FILTERS = { "blocked", "observed", } +_CALLBACK_REPLY_STATUS_FILTERS = { + "no_callback", + "sent", + "fallback_sent", + "rescue_sent", + "failed", + "observed", +} # ============================================================================= # Tenants @@ -146,12 +154,14 @@ async def list_runs( project_id: str | None, state: str | None, remediation_status: str | None, + callback_reply_status: str | None, incident_id: str | None, page: int, per_page: int, ) -> dict[str, Any]: - """列出 runs,支援 project_id、state、remediation_status、incident_id filter 與分頁。""" + """列出 runs,支援 project/state/evidence/callback/incident filter 與分頁。""" _validate_remediation_status_filter(remediation_status) + _validate_callback_reply_status_filter(callback_reply_status) _validate_incident_id_filter(incident_id) async with get_db_context("awoooi") as db: @@ -162,7 +172,7 @@ async def list_runs( stmt = stmt.where(AwoooPRunState.state == state) offset = (page - 1) * per_page - if remediation_status or incident_id: + if remediation_status or incident_id or callback_reply_status: result = await db.execute(stmt) candidate_rows = list(result.scalars().all()) context_limit = _list_filter_context_limit(len(candidate_rows)) @@ -176,6 +186,10 @@ async def list_runs( inbound_by_run=inbound_by_run, outbound_by_run=outbound_by_run, ) + callback_reply_summaries = { + row.run_id: _run_callback_reply_summary(outbound_by_run.get(row.run_id, [])) + for row in candidate_rows + } filtered_rows = [ row for row in candidate_rows @@ -187,6 +201,10 @@ async def list_runs( remediation_summaries.get(row.run_id), incident_id, ) + and _callback_reply_summary_matches_status( + callback_reply_summaries.get(row.run_id), + callback_reply_status, + ) ] total = len(filtered_rows) rows = filtered_rows[offset : offset + per_page] @@ -204,6 +222,10 @@ async def list_runs( inbound_by_run=inbound_by_run, outbound_by_run=outbound_by_run, ) + callback_reply_summaries = { + row.run_id: _run_callback_reply_summary(outbound_by_run.get(row.run_id, [])) + for row in rows + } runs = [ { @@ -217,9 +239,7 @@ async def list_runs( "created_at": r.created_at, "timeout_at": r.timeout_at, "remediation_summary": remediation_summaries.get(r.run_id), - "callback_reply_summary": _run_callback_reply_summary( - outbound_by_run.get(r.run_id, []) - ), + "callback_reply_summary": callback_reply_summaries.get(r.run_id), } for r in rows ] @@ -738,6 +758,17 @@ def _validate_remediation_status_filter(value: str | None) -> None: ) +def _validate_callback_reply_status_filter(value: str | None) -> None: + if value is None: + return + if value not in _CALLBACK_REPLY_STATUS_FILTERS: + allowed = ", ".join(sorted(_CALLBACK_REPLY_STATUS_FILTERS)) + raise HTTPException( + status_code=422, + detail=f"callback_reply_status 必須是: {allowed}", + ) + + def _validate_incident_id_filter(value: str | None) -> None: if value is None: return @@ -758,6 +789,16 @@ def _remediation_summary_matches_status( return status_value == remediation_status +def _callback_reply_summary_matches_status( + summary: dict[str, Any] | None, + callback_reply_status: str | None, +) -> bool: + if callback_reply_status is None: + return True + status_value = str((summary or {}).get("status") or "no_callback") + return status_value == callback_reply_status + + def _remediation_summary_matches_incident_id( summary: dict[str, Any] | None, incident_id: str | None, diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 8f1dc549..cc4280de 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -3,8 +3,12 @@ from decimal import Decimal from types import SimpleNamespace from uuid import UUID +import pytest +from fastapi import HTTPException + from src.api.v1.platform.operator_runs import ListRunsResponse from src.services.platform_operator_service import ( + _callback_reply_summary_matches_status, _collect_run_incident_ids, _legacy_mcp_timeline_status, _legacy_mcp_timeline_summary, @@ -18,6 +22,7 @@ from src.services.platform_operator_service import ( _run_callback_reply_summary, _run_remediation_list_summary, _timeline_sort_key, + _validate_callback_reply_status_filter, ) @@ -420,6 +425,31 @@ def test_remediation_summary_matches_status_filter() -> None: assert _remediation_summary_matches_status(None, "no_evidence") +def test_callback_reply_summary_matches_status_filter() -> None: + assert _callback_reply_summary_matches_status( + {"status": "failed"}, + "failed", + ) + assert _callback_reply_summary_matches_status( + {"status": "fallback_sent"}, + "fallback_sent", + ) + assert not _callback_reply_summary_matches_status( + {"status": "sent"}, + "failed", + ) + assert _callback_reply_summary_matches_status(None, "no_callback") + + +def test_callback_reply_status_filter_rejects_unknown_value() -> None: + _validate_callback_reply_status_filter("failed") + with pytest.raises(HTTPException) as exc_info: + _validate_callback_reply_status_filter("telegram_error") + + assert exc_info.value.status_code == 422 + assert "callback_reply_status" in str(exc_info.value.detail) + + def test_remediation_summary_matches_incident_id_filter() -> None: assert _remediation_summary_matches_incident_id( {"incident_ids": ["INC-20260514-F85F21"]}, diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index eac7b3d9..1037d303 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1876,6 +1876,10 @@ "emptyShort": "No detail / history callback yet", "latest": "{action} · {incidentId}", "needsHuman": "Callback failure needs human review", + "filters": { + "label": "TG Callback filter", + "all": "All TG callbacks" + }, "statuses": { "noCallback": "No callback", "sent": "Delivered", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 4d57a9fa..f3f3c047 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1877,6 +1877,10 @@ "emptyShort": "尚無詳情 / 歷史 callback", "latest": "{action} · {incidentId}", "needsHuman": "Callback 失敗需人工確認", + "filters": { + "label": "TG Callback 篩選", + "all": "所有 TG Callback" + }, "statuses": { "noCallback": "尚無 Callback", "sent": "已送達", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 2ca166e1..284dffbf 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -346,6 +346,14 @@ const CALLBACK_REPLY_CONFIG: Record< className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", }, }; +const CALLBACK_REPLY_FILTER_OPTIONS: CallbackReplyStatus[] = [ + "failed", + "fallback_sent", + "rescue_sent", + "sent", + "observed", + "no_callback", +]; function getRunLane(state: RunState): RunLane { if (state === "pending") return "intake"; @@ -714,6 +722,7 @@ function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) { export default function RunsPage() { const tEvidence = useTranslations("awooop.listEvidence"); + const tCallback = useTranslations("awooop.callbackReply"); const [runs, setRuns] = useState([]); const [groupedEvents, setGroupedEvents] = useState([]); const [tenants, setTenants] = useState([]); @@ -723,6 +732,7 @@ export default function RunsPage() { const [projectFilter, setProjectFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); + const [callbackFilter, setCallbackFilter] = useState<"" | CallbackReplyStatus>(""); const [incidentFilter, setIncidentFilter] = useState(""); const [page, setPage] = useState(1); const [lastRefresh, setLastRefresh] = useState(null); @@ -732,9 +742,17 @@ export default function RunsPage() { const params = new URLSearchParams(window.location.search); const linkedProject = params.get("project_id") || ""; const linkedIncident = params.get("incident_id") || ""; + const linkedCallbackStatus = params.get("callback_reply_status") || ""; if (linkedProject) setProjectFilter(linkedProject); if (linkedIncident) setIncidentFilter(linkedIncident); - if (linkedProject || linkedIncident) setPage(1); + if ( + CALLBACK_REPLY_FILTER_OPTIONS.includes( + linkedCallbackStatus as CallbackReplyStatus + ) + ) { + setCallbackFilter(linkedCallbackStatus as CallbackReplyStatus); + } + if (linkedProject || linkedIncident || linkedCallbackStatus) setPage(1); }, []); // 取得租戶清單 @@ -755,6 +773,7 @@ export default function RunsPage() { if (projectFilter) params.set("project_id", projectFilter); if (statusFilter) params.set("state", statusFilter); if (evidenceFilter) params.set("remediation_status", evidenceFilter); + if (callbackFilter) params.set("callback_reply_status", callbackFilter); const normalizedIncidentFilter = incidentFilter.trim().toUpperCase(); if (INCIDENT_ID_FILTER_RE.test(normalizedIncidentFilter)) { params.set("incident_id", normalizedIncidentFilter); @@ -791,7 +810,7 @@ export default function RunsPage() { } finally { setLoading(false); } - }, [projectFilter, statusFilter, evidenceFilter, incidentFilter, page]); + }, [projectFilter, statusFilter, evidenceFilter, callbackFilter, incidentFilter, page]); // 初次載入 useEffect(() => { @@ -1038,6 +1057,27 @@ export default function RunsPage() {