fix(awooop): surface legacy HITL backlog
All checks were successful
CD Pipeline / tests (push) Successful in 1m31s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 3m40s
CD Pipeline / post-deploy-checks (push) Successful in 1m38s

This commit is contained in:
Your Name
2026-05-25 23:46:50 +08:00
parent a21cb05af3
commit 88b19259c5
5 changed files with 284 additions and 9 deletions

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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 (
<section className="border border-[#d9b36f] bg-[#fffaf0]">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#ead9b4] bg-[#fff7e8] px-4 py-3">
<div className="flex items-start gap-3">
<MessageSquareWarning className="mt-0.5 h-5 w-5 text-[#8a5a08]" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">{tLegacy("title")}</h3>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{tLegacy("subtitle")}</p>
</div>
</div>
<Link
href="/authorizations"
className="inline-flex items-center gap-2 border border-[#d9b36f] bg-white px-3 py-2 text-xs font-semibold text-[#8a5a08] transition hover:bg-[#fff3d6]"
>
{tLegacy("openAuthorizations")}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
<div className="grid gap-px bg-[#ead9b4] md:grid-cols-4">
{[
[tLegacy("summary.pending"), approvals.length],
[tLegacy("summary.noTelegram"), noTelegramCount],
[tLegacy("summary.observe"), observeCount],
[tLegacy("summary.critical"), criticalCount],
].map(([label, value]) => (
<div key={label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{value}</p>
</div>
))}
</div>
{error && (
<div className="flex items-start gap-2 border-t border-[#ead9b4] bg-[#fff0ef] px-4 py-3 text-xs text-[#9f2f25]">
<AlertCircle className="mt-0.5 h-4 w-4" aria-hidden="true" />
{tLegacy("loadFailed", { error })}
</div>
)}
{(loading || approvals.length > 0) && (
<div className="overflow-x-auto bg-white">
<table className="w-full" aria-label={tLegacy("tableLabel")}>
<thead>
<tr className="border-b border-[#e0ddd4] bg-[#faf9f3]">
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.risk")}</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.action")}</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.incident")}</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.source")}</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.created")}</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 4 }).map((_, row) => (
<tr key={row} className="border-b border-[#f0ede5]">
{Array.from({ length: 5 }).map((_, cell) => (
<td key={cell} className="px-4 py-3">
<div className="h-4 w-24 animate-pulse rounded bg-[#f0ede5]" />
</td>
))}
</tr>
))
) : (
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 (
<tr key={approval.id} className="border-b border-[#f0ede5]">
<td className="px-4 py-3 align-top">
<span className={cn("inline-flex border px-2 py-0.5 text-xs font-semibold", riskClass)}>
{risk}
</span>
</td>
<td className="max-w-[420px] px-4 py-3 align-top">
<p className="truncate text-sm font-semibold text-[#141413]">{approval.action}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-[#5f5b52]">
{approval.description || "--"}
</p>
</td>
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
{approval.incident_id || "--"}
</td>
<td className="px-4 py-3 align-top text-xs text-[#5f5b52]">
{approval.telegram_message_id
? tLegacy("telegramRef", { id: approval.telegram_message_id })
: tLegacy("noTelegram")}
</td>
<td className="px-4 py-3 align-top text-xs text-[#5f5b52]">
{formatLocalTime(approval.created_at)}
</td>
</tr>
);
})
)}
</tbody>
</table>
{!loading && approvals.length > 8 && (
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs text-[#5f5b52]">
{tLegacy("moreRows", { count: approvals.length - 8 })}
</div>
)}
</div>
)}
</section>
);
}
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<Approval[]>([]);
const [legacyApprovals, setLegacyApprovals] = useState<LegacyApproval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [legacyError, setLegacyError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
const intervalRef = useRef<ReturnType<typeof setInterval> | 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() {
<SecurityOwnerResponseGatePanel />
<GitHubPrimaryReadinessApprovalBoundaryPanel />
<OwnerResponseValidationApprovalBoundaryPanel />
<LegacyHitlBacklogPanel
approvals={legacyApprovals}
loading={loading}
error={legacyError}
/>
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />