feat(awooop): filter runs by callback reply state
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m9s
CD Pipeline / build-and-deploy (push) Successful in 3m53s
CD Pipeline / post-deploy-checks (push) Successful in 1m40s

This commit is contained in:
Your Name
2026-05-18 15:54:21 +08:00
parent e81e3f7b8a
commit f3494e0bfb
6 changed files with 133 additions and 8 deletions

View File

@@ -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 證據狀態 filterno_evidence/mcp_observed/read_only_dry_run/write_observed/blocked/observed",
),
callback_reply_status: str | None = Query(
None,
description="Telegram callback reply 狀態 filterno_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,

View File

@@ -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,

View File

@@ -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"]},

View File

@@ -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",

View File

@@ -1877,6 +1877,10 @@
"emptyShort": "尚無詳情 / 歷史 callback",
"latest": "{action} · {incidentId}",
"needsHuman": "Callback 失敗需人工確認",
"filters": {
"label": "TG Callback 篩選",
"all": "所有 TG Callback"
},
"statuses": {
"noCallback": "尚無 Callback",
"sent": "已送達",

View File

@@ -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<Run[]>([]);
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -723,6 +732,7 @@ export default function RunsPage() {
const [projectFilter, setProjectFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
const [callbackFilter, setCallbackFilter] = useState<"" | CallbackReplyStatus>("");
const [incidentFilter, setIncidentFilter] = useState<string>("");
const [page, setPage] = useState(1);
const [lastRefresh, setLastRefresh] = useState<Date | null>(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() {
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
{/* TG Callback Filter */}
<div className="relative">
<select
value={callbackFilter}
onChange={(e) => {
setCallbackFilter(e.target.value as "" | CallbackReplyStatus);
setPage(1);
}}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label={tCallback("filters.label")}
>
<option value="">{tCallback("filters.all")}</option>
{CALLBACK_REPLY_FILTER_OPTIONS.map((status) => (
<option key={status} value={status}>
{tCallback(CALLBACK_REPLY_CONFIG[status].labelKey)}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
{/* Incident Filter */}
<input
value={incidentFilter}