From 88b19259c54bbcded31d29a62bf1fa64c1931f7c Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 25 May 2026 23:46:50 +0800 Subject: [PATCH] fix(awooop): surface legacy HITL backlog --- apps/api/src/api/v1/dashboard.py | 7 +- .../src/services/dashboard_metrics_service.py | 31 +++ apps/web/messages/en.json | 23 ++ apps/web/messages/zh-TW.json | 23 ++ .../app/[locale]/awooop/approvals/page.tsx | 209 +++++++++++++++++- 5 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/services/dashboard_metrics_service.py diff --git a/apps/api/src/api/v1/dashboard.py b/apps/api/src/api/v1/dashboard.py index b6691c69..dc45e60b 100644 --- a/apps/api/src/api/v1/dashboard.py +++ b/apps/api/src/api/v1/dashboard.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from src.core.config import settings from src.core.logging import get_logger from src.core.sse import EventPublisher, EventType, SSEEvent, get_publisher +from src.services.dashboard_metrics_service import fetch_pending_approval_count from src.services.host_aggregator import AggregatedStatus, HostAggregator router = APIRouter() @@ -141,12 +142,14 @@ async def dashboard_update_loop(publisher: EventPublisher) -> None: try: # Fetch aggregated status status = await HostAggregator.fetch_all() + pending_approvals = await fetch_pending_approval_count() # Publish to all connected clients event = SSEEvent( type=EventType.HOST_UPDATE, data={ "overall_status": status.overall_status, + "pending_approvals": pending_approvals, "hosts": [ { "ip": h.ip, @@ -206,7 +209,9 @@ async def get_dashboard() -> DashboardResponse: logger.info("dashboard_fetch") status = await HostAggregator.fetch_all() - return aggregated_to_response(status) + response = aggregated_to_response(status) + response.pending_approvals = await fetch_pending_approval_count() + return response @router.get("/dashboard/stream") diff --git a/apps/api/src/services/dashboard_metrics_service.py b/apps/api/src/services/dashboard_metrics_service.py new file mode 100644 index 00000000..9dc46069 --- /dev/null +++ b/apps/api/src/services/dashboard_metrics_service.py @@ -0,0 +1,31 @@ +""" +Dashboard Metrics Service +========================= +Small DB-backed counters used by the war-room dashboard. +""" + +from sqlalchemy import text + +from src.core.logging import get_logger +from src.db.base import get_db_context + +logger = get_logger("awoooi.dashboard_metrics") + + +async def fetch_pending_approval_count() -> int: + """Read the live HITL backlog from approval_records.""" + try: + async with get_db_context() as db: + result = await db.execute( + text( + """ + SELECT count(*) + FROM approval_records + WHERE upper(status::text) = 'PENDING' + """ + ) + ) + return int(result.scalar_one() or 0) + except Exception as exc: + logger.warning("dashboard_pending_approval_count_failed", error=str(exc)) + return 0 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 69d21779..c89d1ab7 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1680,6 +1680,29 @@ "escalate_verification_failure": "Escalate verification failure", "inspect_degraded_evidence": "Inspect degraded evidence" } + }, + "legacyHitl": { + "title": "Legacy HITL Pending", + "subtitle": "These items come from approval_records, not AwoooP run approvals. They still need to be visible in the operator console.", + "openAuthorizations": "Open Authorizations", + "loadFailed": "Failed to load Legacy HITL backlog: {error}", + "tableLabel": "Legacy HITL pending approvals", + "moreRows": "Showing the latest 8 items. Open Authorizations for the remaining {count}.", + "noTelegram": "no TG", + "telegramRef": "TG #{id}", + "summary": { + "pending": "Pending", + "noTelegram": "No Telegram message", + "observe": "Observe/no action", + "critical": "Critical" + }, + "columns": { + "risk": "Risk", + "action": "Action", + "incident": "Incident", + "source": "Source", + "created": "Created" + } } }, "events": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ef3dcc31..3b3c4fed 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1681,6 +1681,29 @@ "escalate_verification_failure": "升級驗證失敗", "inspect_degraded_evidence": "檢查降級證據" } + }, + "legacyHitl": { + "title": "Legacy HITL 待人工處理", + "subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。", + "openAuthorizations": "開啟授權中心", + "loadFailed": "Legacy HITL backlog 載入失敗:{error}", + "tableLabel": "Legacy HITL 待人工處理", + "moreRows": "只顯示最新 8 筆,其餘 {count} 筆請到授權中心處理。", + "noTelegram": "no TG", + "telegramRef": "TG #{id}", + "summary": { + "pending": "待處理", + "noTelegram": "無 Telegram 訊息", + "observe": "觀察/無動作", + "critical": "Critical" + }, + "columns": { + "risk": "風險", + "action": "動作", + "incident": "事件", + "source": "來源", + "created": "建立" + } } }, "events": { diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 9f34e0b4..dd5d1523 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -14,8 +14,10 @@ import { Clock, ArrowRight, ChevronDown, + ExternalLink, Filter, ListChecks, + MessageSquareWarning, SearchCheck, TriangleAlert, GitBranch, @@ -65,6 +67,20 @@ interface Approval { awooop_status_chain?: AwoooPStatusChain | null; } +interface LegacyApproval { + id: string; + action: string; + description?: string | null; + status: string; + risk_level?: string | null; + incident_id?: string | null; + created_at: string; + expires_at?: string | null; + telegram_message_id?: number | null; + current_signatures?: number | null; + required_signatures?: number | null; +} + // ============================================================================= // 常數 // ============================================================================= @@ -94,6 +110,18 @@ function formatRemaining(ms: number): string { return `${seconds}s`; } +function formatLocalTime(value?: string | null): string { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-TW", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + // ============================================================================= // Sub Components // ============================================================================= @@ -209,6 +237,137 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n ); } +function LegacyHitlBacklogPanel({ + approvals, + loading, + error, +}: { + approvals: LegacyApproval[]; + loading: boolean; + error: string | null; +}) { + const tLegacy = useTranslations("awooop.approvals.legacyHitl"); + const noTelegramCount = approvals.filter((approval) => !approval.telegram_message_id).length; + const criticalCount = approvals.filter( + (approval) => approval.risk_level?.toLowerCase() === "critical" + ).length; + const observeCount = approvals.filter((approval) => + /^(OBSERVE|NO_ACTION)/i.test(approval.action) + ).length; + + if (!loading && approvals.length === 0 && !error) return null; + + return ( +
+
+
+
+ + {tLegacy("openAuthorizations")} +
+ +
+ {[ + [tLegacy("summary.pending"), approvals.length], + [tLegacy("summary.noTelegram"), noTelegramCount], + [tLegacy("summary.observe"), observeCount], + [tLegacy("summary.critical"), criticalCount], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {error && ( +
+
+ )} + + {(loading || approvals.length > 0) && ( +
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 4 }).map((_, row) => ( + + {Array.from({ length: 5 }).map((_, cell) => ( + + ))} + + )) + ) : ( + approvals.slice(0, 8).map((approval) => { + const risk = approval.risk_level?.toLowerCase() ?? "unknown"; + const riskClass = + risk === "critical" + ? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]" + : "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"; + return ( + + + + + + + + ); + }) + )} + +
{tLegacy("columns.risk")}{tLegacy("columns.action")}{tLegacy("columns.incident")}{tLegacy("columns.source")}{tLegacy("columns.created")}
+
+
+ + {risk} + + +

{approval.action}

+

+ {approval.description || "--"} +

+
+ {approval.incident_id || "--"} + + {approval.telegram_message_id + ? tLegacy("telegramRef", { id: approval.telegram_message_id }) + : tLegacy("noTelegram")} + + {formatLocalTime(approval.created_at)} +
+ {!loading && approvals.length > 8 && ( +
+ {tLegacy("moreRows", { count: approvals.length - 8 })} +
+ )} +
+ )} +
+ ); +} + type ApprovalSourceFlowStatus = "verified" | "applied" | "evidence" | "provider" | "waiting"; function approvalSourceFlowStatus(chain?: AwoooPStatusChain | null): ApprovalSourceFlowStatus { @@ -729,24 +888,49 @@ export default function ApprovalsPage() { const tEvidence = useTranslations("awooop.listEvidence"); const tStatusChain = useTranslations("awooop.statusChain"); const [approvals, setApprovals] = useState([]); + const [legacyApprovals, setLegacyApprovals] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [legacyError, setLegacyError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); const intervalRef = useRef | null>(null); const fetchApprovals = useCallback(async () => { + const fetchJson = async (url: string) => { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }; + try { setError(null); + setLegacyError(null); const params = new URLSearchParams(); if (evidenceFilter) params.set("remediation_status", evidenceFilter); const qs = params.toString(); - const res = await fetch( - `${API_BASE}/api/v1/platform/approvals${qs ? `?${qs}` : ""}` - ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setApprovals(Array.isArray(data.items) ? data.items : []); + const [platformResult, legacyResult] = await Promise.allSettled([ + fetchJson(`${API_BASE}/api/v1/platform/approvals${qs ? `?${qs}` : ""}`), + fetchJson(`${API_BASE}/api/v1/approvals/pending`), + ]); + + if (platformResult.status === "fulfilled") { + const data = platformResult.value; + setApprovals(Array.isArray(data.items) ? data.items : []); + } else { + setApprovals([]); + setError(platformResult.reason instanceof Error ? platformResult.reason.message : "載入失敗"); + } + + if (legacyResult.status === "fulfilled") { + const data = legacyResult.value; + const pending = Array.isArray(data) ? data : data.approvals ?? []; + setLegacyApprovals(Array.isArray(pending) ? pending : []); + } else { + setLegacyApprovals([]); + setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : "載入失敗"); + } + setLastRefresh(new Date()); } catch (err) { setError(err instanceof Error ? err.message : "載入失敗"); @@ -769,6 +953,8 @@ export default function ApprovalsPage() { }; }, [fetchApprovals]); + const legacyPendingCount = legacyApprovals.length; + const totalPendingCount = approvals.length + legacyPendingCount; const criticalCount = approvals.filter((a) => { const ms = getRemainingMs(a.timeout_at); return ms !== null && ms <= 5 * 60 * 1000; @@ -790,8 +976,8 @@ export default function ApprovalsPage() { () => [ { label: "待人工決策", - value: approvals.length, - detail: "等待批准 / 駁回的唯一佇列", + value: totalPendingCount, + detail: `AwoooP ${approvals.length} / Legacy HITL ${legacyPendingCount}`, icon: ShieldCheck, className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", }, @@ -833,11 +1019,13 @@ export default function ApprovalsPage() { ], [ approvals.length, + legacyPendingCount, criticalCount, expiredCount, mcpObservedCount, noEvidenceCount, readOnlyEvidenceCount, + totalPendingCount, tEvidence, ] ); @@ -910,6 +1098,11 @@ export default function ApprovalsPage() { +