fix(awooop): surface legacy HITL backlog
This commit is contained in:
@@ -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")
|
||||
|
||||
31
apps/api/src/services/dashboard_metrics_service.py
Normal file
31
apps/api/src/services/dashboard_metrics_service.py
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user