feat(awooop): filter runs by callback reply state
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1877,6 +1877,10 @@
|
||||
"emptyShort": "尚無詳情 / 歷史 callback",
|
||||
"latest": "{action} · {incidentId}",
|
||||
"needsHuman": "Callback 失敗需人工確認",
|
||||
"filters": {
|
||||
"label": "TG Callback 篩選",
|
||||
"all": "所有 TG Callback"
|
||||
},
|
||||
"statuses": {
|
||||
"noCallback": "尚無 Callback",
|
||||
"sent": "已送達",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user