From ff6a7c16112954040436a390aefd5384ea6eb87c Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 20:26:25 +0800 Subject: [PATCH] fix(web): surface incident truth chain in approvals --- apps/web/messages/en.json | 77 ++++ apps/web/messages/zh-TW.json | 77 ++++ .../app/[locale]/awooop/approvals/page.tsx | 376 ++++++++++++++++-- 3 files changed, 500 insertions(+), 30 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e77cdfe3..24eb8d50 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -4128,6 +4128,83 @@ } }, "approvals": { + "page": { + "title": "審批佇列", + "urgentCount": "{count} 緊急", + "loading": "載入中...", + "pendingRefresh": "{count} 筆待審 · 上次更新 {time}", + "autoRefresh": "每 10 秒自動刷新", + "refreshNow": "立即刷新", + "loadFailed": "無法載入審批資料", + "genericLoadFailed": "載入失敗" + }, + "summary": { + "pending": "待人工決策", + "pendingDetail": "AwoooP {platform} / Legacy HITL {legacy}", + "critical": "即將逾時", + "criticalDetail": "5 分鐘內必須處置", + "expired": "已逾時", + "expiredDetail": "不得再自動恢復" + }, + "badges": { + "humanGate": "人工閘門" + }, + "columns": { + "runId": "執行 ID", + "projectId": "專案 ID", + "agent": "AI 代理", + "route": "處置路線", + "created": "建立時間", + "remaining": "剩餘時間" + }, + "empty": { + "title": "審批佇列為空", + "subtitle": "目前沒有待審批的執行項目" + }, + "incidentFocus": { + "title": "焦點 Incident 審批真相鏈", + "loading": "讀取中", + "loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。", + "openWorkItems": "Work Items", + "openRuns": "Runs", + "openTickets": "Tickets", + "empty": "無", + "flowTitle": "處理流程", + "handoffTitle": "審批與人工接手", + "timelineEmpty": "尚未取得 Incident timeline。", + "linkedExplanation": "此 Incident 已有 approval / timeline 關聯;若下方待審清單為空,代表它可能已完成、過期、拒絕,或已轉成驗證後人工接手。", + "unlinkedExplanation": "目前沒有對應 approval id;這代表此 Incident 不是等待批准的狀態,應從 Work Items / Runs 追下一步。", + "needsHuman": { + "yes": "需要人工", + "no": "不需人工" + }, + "metrics": { + "approvals": "關聯審批", + "stage": "目前階段", + "repair": "修復狀態", + "verification": "驗證", + "handoff": "人工接手" + }, + "handoff": { + "approvalIds": "Approval IDs", + "pendingRows": "待審列", + "pendingRowsValue": "AwoooP {platform} / Legacy {legacy}", + "nextAction": "下一步", + "reason": "原因" + }, + "evidence": { + "executor": "Executor", + "ansible": "Ansible", + "mcp": "MCP", + "mcpValue": "{success}/{total} success;top {tool}", + "source": "Source", + "sourceValue": "direct {direct} / candidate {candidate} / applied {applied}", + "km": "KM", + "command": "指令判定", + "notification": "通知通道", + "events": "Timeline events" + } + }, "legacyHitl": { "title": "既有 HITL 待人工處理", "subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e77cdfe3..24eb8d50 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -4128,6 +4128,83 @@ } }, "approvals": { + "page": { + "title": "審批佇列", + "urgentCount": "{count} 緊急", + "loading": "載入中...", + "pendingRefresh": "{count} 筆待審 · 上次更新 {time}", + "autoRefresh": "每 10 秒自動刷新", + "refreshNow": "立即刷新", + "loadFailed": "無法載入審批資料", + "genericLoadFailed": "載入失敗" + }, + "summary": { + "pending": "待人工決策", + "pendingDetail": "AwoooP {platform} / Legacy HITL {legacy}", + "critical": "即將逾時", + "criticalDetail": "5 分鐘內必須處置", + "expired": "已逾時", + "expiredDetail": "不得再自動恢復" + }, + "badges": { + "humanGate": "人工閘門" + }, + "columns": { + "runId": "執行 ID", + "projectId": "專案 ID", + "agent": "AI 代理", + "route": "處置路線", + "created": "建立時間", + "remaining": "剩餘時間" + }, + "empty": { + "title": "審批佇列為空", + "subtitle": "目前沒有待審批的執行項目" + }, + "incidentFocus": { + "title": "焦點 Incident 審批真相鏈", + "loading": "讀取中", + "loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。", + "openWorkItems": "Work Items", + "openRuns": "Runs", + "openTickets": "Tickets", + "empty": "無", + "flowTitle": "處理流程", + "handoffTitle": "審批與人工接手", + "timelineEmpty": "尚未取得 Incident timeline。", + "linkedExplanation": "此 Incident 已有 approval / timeline 關聯;若下方待審清單為空,代表它可能已完成、過期、拒絕,或已轉成驗證後人工接手。", + "unlinkedExplanation": "目前沒有對應 approval id;這代表此 Incident 不是等待批准的狀態,應從 Work Items / Runs 追下一步。", + "needsHuman": { + "yes": "需要人工", + "no": "不需人工" + }, + "metrics": { + "approvals": "關聯審批", + "stage": "目前階段", + "repair": "修復狀態", + "verification": "驗證", + "handoff": "人工接手" + }, + "handoff": { + "approvalIds": "Approval IDs", + "pendingRows": "待審列", + "pendingRowsValue": "AwoooP {platform} / Legacy {legacy}", + "nextAction": "下一步", + "reason": "原因" + }, + "evidence": { + "executor": "Executor", + "ansible": "Ansible", + "mcp": "MCP", + "mcpValue": "{success}/{total} success;top {tool}", + "source": "Source", + "sourceValue": "direct {direct} / candidate {candidate} / applied {applied}", + "km": "KM", + "command": "指令判定", + "notification": "通知通道", + "events": "Timeline events" + } + }, "legacyHitl": { "title": "既有 HITL 待人工處理", "subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 8d461bbc..fbfcee45 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -6,6 +6,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { ShieldCheck, @@ -28,6 +29,7 @@ import { AwoooPStatusChainPanel, type AwoooPStatusChain, } from "@/components/awooop/status-chain"; +import type { IncidentTimelineResponse } from "@/lib/api-client"; // ============================================================================= // Types @@ -122,6 +124,49 @@ function formatLocalTime(value?: string | null): string { }); } +async function fetchJson(url: string, timeoutMs = 12_000): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + cache: "no-store", + signal: controller.signal, + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } finally { + window.clearTimeout(timeout); + } +} + +function timelineStatusClass(status?: string | null) { + const normalized = String(status ?? "").toLowerCase(); + if (normalized.includes("success") || normalized.includes("ok") || normalized.includes("resolved")) { + return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; + } + if (normalized.includes("fail") || normalized.includes("block") || normalized.includes("error")) { + return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; + } + if (normalized.includes("warn") || normalized.includes("pending") || normalized.includes("investigating")) { + return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"; + } + return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]"; +} + +function approvalMatchesIncident(approval: Approval, incidentId: string) { + const summaryIds = approval.remediation_summary?.incident_ids ?? []; + const chainIds = approval.awooop_status_chain?.incident_ids ?? []; + return summaryIds.includes(incidentId) + || chainIds.includes(incidentId) + || approval.awooop_status_chain?.source_id === incidentId; +} + +function uniqueValues(values: Array) { + return Array.from(new Set(values.filter((value): value is string => Boolean(value)))); +} + // ============================================================================= // Sub Components // ============================================================================= @@ -464,10 +509,11 @@ function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) { } function DecisionPostureBadge() { + const t = useTranslations("awooop.approvals"); return ( ); } @@ -880,18 +926,230 @@ function OwnerResponseValidationApprovalBoundaryPanel() { ); } +function FocusedIncidentApprovalPanel({ + projectId, + incidentId, + chain, + timeline, + approvals, + legacyApprovals, + loading, + error, +}: { + projectId: string; + incidentId: string; + chain: AwoooPStatusChain | null; + timeline: IncidentTimelineResponse | null; + approvals: Approval[]; + legacyApprovals: LegacyApproval[]; + loading: boolean; + error: string | null; +}) { + const t = useTranslations("awooop.approvals.incidentFocus"); + const encodedProjectId = encodeURIComponent(projectId); + const encodedIncidentId = encodeURIComponent(incidentId); + const platformMatches = approvals.filter((approval) => approvalMatchesIncident(approval, incidentId)); + const legacyMatches = legacyApprovals.filter((approval) => approval.incident_id === incidentId); + const timelineApprovalIds = timeline?.approval_ids ?? []; + const linkedApprovalIds = uniqueValues([ + ...timelineApprovalIds, + ...platformMatches.map((approval) => approval.run_id), + ...legacyMatches.map((approval) => approval.id), + ]); + const stages = timeline?.timeline?.filter((stage) => stage.status !== "skipped") ?? []; + const verifier = timeline?.timeline?.find((stage) => stage.stage === "verifier"); + const executor = timeline?.timeline?.find((stage) => stage.stage === "executor"); + const km = timeline?.timeline?.find((stage) => stage.stage === "km"); + const topMcpTool = chain?.mcp?.top_tools?.[0]?.tool_name ?? "--"; + const ansible = chain?.execution?.ansible; + const outcome = chain?.operator_outcome; + const needsHuman = chain?.needs_human ?? outcome?.needs_human ?? false; + const title = timeline?.title ?? chain?.source_id ?? incidentId; + const sourceCorrelation = chain?.source_refs?.correlation; + + return ( +
+
+
+ + +
+

{t("title")}

+

{incidentId}

+

{title}

+
+
+
+ {loading ? ( + + + ) : null} + + {t("openWorkItems")} +
+
+ + {error ? ( +
+
+ ) : null} + +
+ {[ + [t("metrics.approvals"), linkedApprovalIds.length || "--"], + [t("metrics.stage"), chain?.current_stage ?? "--"], + [t("metrics.repair"), chain?.repair_state ?? "--"], + [t("metrics.verification"), verifier?.status ?? chain?.verification ?? "--"], + [t("metrics.handoff"), needsHuman ? t("needsHuman.yes") : t("needsHuman.no")], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+ + + +
+
+
+
+ {timeline?.ascii_timeline ? ( +

+ {timeline.ascii_timeline} +

+ ) : ( +

+ {loading ? t("loading") : t("timelineEmpty")} +

+ )} + {stages.length > 0 ? ( +
+ {stages.slice(0, 6).map((stage) => ( +
+
+ {stage.label} + + {stage.status} + +
+

+ {stage.title} +

+
+ ))} +
+ ) : null} +
+ +
+
+
+
+ {[ + [t("handoff.approvalIds"), linkedApprovalIds.slice(0, 4).join(", ") || t("empty")], + [t("handoff.pendingRows"), t("handoff.pendingRowsValue", { platform: platformMatches.length, legacy: legacyMatches.length })], + [t("handoff.nextAction"), outcome?.next_action ?? chain?.next_step ?? "--"], + [t("handoff.reason"), outcome?.human_action_reason ?? "--"], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+

+ {linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")} +

+
+
+ +
+ {[ + [t("evidence.executor"), executor?.title ?? chain?.execution?.latest_operation_type ?? "--"], + [t("evidence.ansible"), ansible?.latest_playbook_path ?? ansible?.latest_catalog_id ?? "--"], + [t("evidence.mcp"), t("evidence.mcpValue", { + success: chain?.mcp?.gateway?.success ?? 0, + total: chain?.mcp?.gateway?.total ?? 0, + tool: topMcpTool, + })], + [t("evidence.source"), sourceCorrelation + ? t("evidence.sourceValue", { + direct: sourceCorrelation.direct_ref_total ?? 0, + candidate: sourceCorrelation.candidate_total ?? 0, + applied: sourceCorrelation.applied_link_total ?? 0, + }) + : "--"], + [t("evidence.km"), km?.title ?? String(chain?.evidence?.knowledge_entries ?? 0)], + [t("evidence.command"), outcome?.execution_result?.summary_zh ?? "--"], + [t("evidence.notification"), (outcome?.notification?.channels ?? []).join(", ") || "--"], + [t("evidence.events"), timeline ? String(timeline.events.length) : "--"], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+
+ ); +} + // ============================================================================= // Main Component // ============================================================================= export default function ApprovalsPage() { + const t = useTranslations("awooop.approvals"); const tEvidence = useTranslations("awooop.listEvidence"); const tStatusChain = useTranslations("awooop.statusChain"); + const searchParams = useSearchParams(); + const queryIncidentId = searchParams.get("incident_id"); + const projectId = searchParams.get("project_id") ?? "awoooi"; const [approvals, setApprovals] = useState([]); const [legacyApprovals, setLegacyApprovals] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [legacyError, setLegacyError] = useState(null); + const [incidentChain, setIncidentChain] = useState(null); + const [incidentTimeline, setIncidentTimeline] = useState(null); + const [incidentLoading, setIncidentLoading] = useState(false); + const [incidentError, setIncidentError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); const intervalRef = useRef | null>(null); @@ -919,7 +1177,7 @@ export default function ApprovalsPage() { setApprovals(Array.isArray(data.items) ? data.items : []); } else { setApprovals([]); - setError(platformResult.reason instanceof Error ? platformResult.reason.message : "載入失敗"); + setError(platformResult.reason instanceof Error ? platformResult.reason.message : t("page.genericLoadFailed")); } if (legacyResult.status === "fulfilled") { @@ -928,21 +1186,62 @@ export default function ApprovalsPage() { setLegacyApprovals(Array.isArray(pending) ? pending : []); } else { setLegacyApprovals([]); - setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : "載入失敗"); + setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : t("page.genericLoadFailed")); } setLastRefresh(new Date()); } catch (err) { - setError(err instanceof Error ? err.message : "載入失敗"); + setError(err instanceof Error ? err.message : t("page.genericLoadFailed")); } finally { setLoading(false); } - }, [evidenceFilter]); + }, [evidenceFilter, t]); useEffect(() => { fetchApprovals(); }, [fetchApprovals]); + useEffect(() => { + let cancelled = false; + if (!queryIncidentId) { + setIncidentChain(null); + setIncidentTimeline(null); + setIncidentError(null); + setIncidentLoading(false); + return () => { + cancelled = true; + }; + } + const focusedIncidentId = queryIncidentId; + + async function loadIncidentFocus() { + setIncidentLoading(true); + setIncidentError(null); + const encodedProjectId = encodeURIComponent(projectId); + const encodedIncidentId = encodeURIComponent(focusedIncidentId); + const [statusChain, timeline] = await Promise.all([ + fetchJson( + `${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` + ), + fetchJson( + `${API_BASE}/api/v1/incidents/${encodedIncidentId}/timeline` + ), + ]); + if (cancelled) return; + setIncidentChain(statusChain); + setIncidentTimeline(timeline); + if (!statusChain && !timeline) { + setIncidentError(t("incidentFocus.loadFailed")); + } + setIncidentLoading(false); + } + + loadIncidentFocus(); + return () => { + cancelled = true; + }; + }, [projectId, queryIncidentId, t]); + // 10 秒自動刷新 useEffect(() => { intervalRef.current = setInterval(() => { @@ -975,23 +1274,26 @@ export default function ApprovalsPage() { const queueSummary = useMemo( () => [ { - label: "待人工決策", + label: t("summary.pending"), value: totalPendingCount, - detail: `AwoooP ${approvals.length} / Legacy HITL ${legacyPendingCount}`, + detail: t("summary.pendingDetail", { + platform: approvals.length, + legacy: legacyPendingCount, + }), icon: ShieldCheck, className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", }, { - label: "即將逾時", + label: t("summary.critical"), value: criticalCount, - detail: "5 分鐘內必須處置", + detail: t("summary.criticalDetail"), icon: Clock, className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", }, { - label: "已逾時", + label: t("summary.expired"), value: expiredCount, - detail: "不得再自動恢復", + detail: t("summary.expiredDetail"), icon: TriangleAlert, className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", }, @@ -1026,6 +1328,7 @@ export default function ApprovalsPage() { noEvidenceCount, readOnlyEvidenceCount, totalPendingCount, + t, tEvidence, ] ); @@ -1038,32 +1341,33 @@ export default function ApprovalsPage() {