From bdcb059444ff5c48e466442ca7ff455d50aa0620 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 17:57:47 +0800 Subject: [PATCH] fix(web): add incident audit timeline to run detail --- apps/web/messages/en.json | 25 ++ apps/web/messages/zh-TW.json | 25 ++ .../[locale]/awooop/runs/[run_id]/page.tsx | 302 ++++++++++++++++++ 3 files changed, 352 insertions(+) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ea05b61d..a3330aa2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -4592,6 +4592,29 @@ "count": "{count} 筆", "empty": "尚無時間線資料。" }, + "incidentAudit": { + "title": "Incident 稽核時間線", + "empty": "尚無 Incident 稽核時間線。", + "eventsEmpty": "尚無可顯示的稽核事件。", + "stagesTitle": "處理階段", + "matchingTitle": "匹配與採用證據", + "eventsTitle": "稽核事件", + "playbook": "PlayBook / Ansible", + "executor": "Executor", + "km": "KM", + "candidateDetail": "score={score}; state={state}; reasons={reasons}", + "matchingEmpty": "尚無 Sentry / SigNoz 候選匹配;原因:{reason}", + "status": { + "linked": "已連到 Incident timeline", + "empty": "尚無 Incident timeline" + }, + "metrics": { + "stages": "階段", + "events": "事件", + "matches": "Direct / Candidate / Applied", + "verification": "Final verification" + } + }, "gateway": { "title": "MCP 閘道", "emptyState": "尚無紀錄", @@ -4705,6 +4728,7 @@ "completed": "已完成", "error": "錯誤", "failed": "失敗", + "info": "資訊", "pending": "待執行", "received": "已接收", "running": "執行中", @@ -4714,6 +4738,7 @@ "callbackReplyRescueSent": "Callback 救援", "callbackReplyFailed": "Callback 失敗", "shadow": "Shadow", + "skipped": "略過", "success": "成功", "timeout": "已超時", "warning": "警告", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ea05b61d..a3330aa2 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -4592,6 +4592,29 @@ "count": "{count} 筆", "empty": "尚無時間線資料。" }, + "incidentAudit": { + "title": "Incident 稽核時間線", + "empty": "尚無 Incident 稽核時間線。", + "eventsEmpty": "尚無可顯示的稽核事件。", + "stagesTitle": "處理階段", + "matchingTitle": "匹配與採用證據", + "eventsTitle": "稽核事件", + "playbook": "PlayBook / Ansible", + "executor": "Executor", + "km": "KM", + "candidateDetail": "score={score}; state={state}; reasons={reasons}", + "matchingEmpty": "尚無 Sentry / SigNoz 候選匹配;原因:{reason}", + "status": { + "linked": "已連到 Incident timeline", + "empty": "尚無 Incident timeline" + }, + "metrics": { + "stages": "階段", + "events": "事件", + "matches": "Direct / Candidate / Applied", + "verification": "Final verification" + } + }, "gateway": { "title": "MCP 閘道", "emptyState": "尚無紀錄", @@ -4705,6 +4728,7 @@ "completed": "已完成", "error": "錯誤", "failed": "失敗", + "info": "資訊", "pending": "待執行", "received": "已接收", "running": "執行中", @@ -4714,6 +4738,7 @@ "callbackReplyRescueSent": "Callback 救援", "callbackReplyFailed": "Callback 失敗", "shadow": "Shadow", + "skipped": "略過", "success": "成功", "timeout": "已超時", "warning": "警告", diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 96ec365d..7b90b85a 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -13,6 +13,7 @@ import { AlertCircle, ArrowLeft, ArrowRight, + BookOpenCheck, CheckCircle2, Clock, FileSearch, @@ -217,6 +218,38 @@ interface RunDetailResponse { }; } +interface IncidentTimelineEvent { + stage: string; + status: string; + title: string; + description?: string | null; + actor?: string | null; + timestamp?: string | null; + source_table?: string | null; + data?: Record; +} + +interface IncidentTimelineStage extends IncidentTimelineEvent { + label: string; + events?: IncidentTimelineEvent[]; +} + +interface IncidentTimelineResponse { + incident_id: string; + title: string; + status: string; + severity: string; + started_at?: string | null; + updated_at?: string | null; + resolved_at?: string | null; + affected_services?: string[]; + approval_ids?: string[]; + timeline: IncidentTimelineStage[]; + events: IncidentTimelineEvent[]; + ascii_timeline: string; + reconciliation?: Record; +} + const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; const AUTO_REFRESH_INTERVAL = 30_000; @@ -275,8 +308,10 @@ const STATUS_STYLE: Record = { callback_reply_fallback_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", callback_reply_rescue_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", callback_reply_failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + info: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", received: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + skipped: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", pending: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", shadow: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", @@ -294,6 +329,7 @@ const STATUS_TRANSLATION_KEYS: Record = { completed: "statuses.completed", error: "statuses.error", failed: "statuses.failed", + info: "statuses.info", pending: "statuses.pending", received: "statuses.received", running: "statuses.running", @@ -303,6 +339,7 @@ const STATUS_TRANSLATION_KEYS: Record = { callback_reply_rescue_sent: "statuses.callbackReplyRescueSent", callback_reply_failed: "statuses.callbackReplyFailed", shadow: "statuses.shadow", + skipped: "statuses.skipped", success: "statuses.success", timeout: "statuses.timeout", waiting_approval: "statuses.waitingApproval", @@ -372,6 +409,15 @@ function booleanLabel(value: boolean | null | undefined, emptyLabel: string) { return emptyLabel; } +function metadataValue(data: Record | undefined, keys: string[]) { + if (!data) return null; + for (const key of keys) { + const value = data[key]; + if (value !== null && value !== undefined && value !== "") return String(value); + } + return null; +} + function RunActionPanel({ run, counts, @@ -911,6 +957,232 @@ function RemediationEvidencePanel({ ); } +function IncidentAuditTimelinePanel({ + timeline, + chain, + loading, + locale, + emptyLabel, + statusLabel, +}: { + timeline?: IncidentTimelineResponse | null; + chain?: AwoooPStatusChain | null; + loading: boolean; + locale: string; + emptyLabel: string; + statusLabel: (status: string) => string; +}) { + const t = useTranslations("runDetail.incidentAudit"); + const incidentId = chain?.source_id ?? timeline?.incident_id ?? null; + const stages = timeline?.timeline?.filter((stage) => stage.status !== "skipped") ?? []; + const sourceCorrelation = chain?.source_refs?.correlation; + const candidates = sourceCorrelation?.top_candidates ?? []; + const ansible = chain?.execution?.ansible; + const verifier = timeline?.timeline?.find((stage) => stage.stage === "verifier"); + const kmStage = timeline?.timeline?.find((stage) => stage.stage === "km"); + const executor = timeline?.timeline?.find((stage) => stage.stage === "executor"); + const sourceReason = sourceCorrelation?.missing_reason ?? emptyLabel; + const selectedPlaybook = chain?.execution?.playbook_paths?.[0] + ?? chain?.execution?.playbook_ids?.[0] + ?? ansible?.latest_catalog_id + ?? ansible?.latest_playbook_path + ?? ansible?.candidate_playbooks?.[0]?.playbook_path + ?? ansible?.candidate_playbooks?.[0]?.catalog_id + ?? emptyLabel; + const importantEvents = (timeline?.events ?? []) + .filter((event) => ( + event.source_table === "automation_operation_log" + || event.source_table === "knowledge_entries" + || event.source_table === "incident_evidence" + || event.source_table === "alert_operation_log" + || event.stage === "verifier" + || event.stage === "km" + || event.stage === "executor" + || event.stage === "ai_router" + )) + .slice(-8) + .reverse(); + const metrics = [ + { label: t("metrics.stages"), value: stages.length }, + { label: t("metrics.events"), value: timeline?.events?.length ?? 0 }, + { + label: t("metrics.matches"), + value: `${sourceCorrelation?.direct_ref_total ?? 0}/${sourceCorrelation?.candidate_total ?? 0}/${sourceCorrelation?.applied_link_total ?? 0}`, + }, + { label: t("metrics.verification"), value: verifier?.status ?? chain?.verification ?? emptyLabel }, + ]; + + if (loading && !timeline) { + return ( +
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+
+
+
+ + {timeline ? t("status.linked") : t("status.empty")} + +
+ +
+ {metrics.map((item) => ( +
+

{item.label}

+

+ {item.value} +

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

+ {stage.title} +

+

+ {stage.source_table ?? emptyLabel} +

+
+ ))} +
+ ) : ( +

{t("empty")}

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

+ {timeline.ascii_timeline} +

+ )} +
+ +
+
+
+
+
+

{t("playbook")}

+

{selectedPlaybook}

+
+
+

{t("executor")}

+

{executor?.status ?? emptyLabel}

+
+
+

{t("km")}

+

{kmStage?.status ?? emptyLabel}

+
+
+ + {candidates.length > 0 ? ( +
+ {candidates.slice(0, 3).map((candidate) => ( +
+ {candidate.provider ?? emptyLabel} +
+

+ {candidate.provider_event_id ?? emptyLabel} +

+

+ {t("candidateDetail", { + score: candidate.score ?? 0, + state: candidate.link_state ?? emptyLabel, + reasons: (candidate.reasons ?? []).join(", ") || emptyLabel, + })} +

+
+
+ ))} +
+ ) : ( +

+ {t("matchingEmpty", { reason: sourceReason })} +

+ )} +
+
+ +
+
+
+
+ {importantEvents.length > 0 ? ( +
+ {importantEvents.map((event, index) => { + const primaryMeta = metadataValue(event.data, ["operation_type", "verification_result", "playbook_id", "matched_playbook_id"]); + const secondaryMeta = metadataValue(event.data, ["status", "duration_ms", "execution_kind", "repair_executed"]); + return ( +
+
+ {formatTime(event.timestamp, locale, emptyLabel)} +
+
+
+ + {statusLabel(event.status)} + + {event.source_table ?? emptyLabel} +
+

{event.title}

+ {event.description && ( +

{event.description}

+ )} + {(primaryMeta || secondaryMeta) && ( +

+ {[primaryMeta, secondaryMeta].filter(Boolean).join(" / ")} +

+ )} +
+
+ ); + })} +
+ ) : ( +
{t("eventsEmpty")}
+ )} +
+ ); +} + function TimelineRow({ item, locale, @@ -973,6 +1245,8 @@ export default function RunDetailPage({ const [detail, setDetail] = useState(null); const [dossier, setDossier] = useState(null); + const [incidentTimeline, setIncidentTimeline] = useState(null); + const [incidentTimelineLoading, setIncidentTimelineLoading] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); @@ -987,6 +1261,23 @@ export default function RunDetailPage({ if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: RunDetailResponse = await res.json(); setDetail(data); + const timelineIncidentId = data.awooop_status_chain?.source_id + || data.remediation_history?.incident_ids?.[0] + || null; + if (timelineIncidentId) { + setIncidentTimelineLoading(true); + try { + const timelineRes = await fetch(`${API_BASE}/api/v1/incidents/${encodeURIComponent(timelineIncidentId)}/timeline`); + setIncidentTimeline(timelineRes.ok ? await timelineRes.json() as IncidentTimelineResponse : null); + } catch { + setIncidentTimeline(null); + } finally { + setIncidentTimelineLoading(false); + } + } else { + setIncidentTimeline(null); + setIncidentTimelineLoading(false); + } const dossierProjectId = projectId || data.run?.project_id; const dossierQuery = new URLSearchParams(); dossierQuery.set("run_id", run_id); @@ -1000,6 +1291,8 @@ export default function RunDetailPage({ } setLastRefresh(new Date()); } catch (err) { + setIncidentTimeline(null); + setIncidentTimelineLoading(false); setError(err instanceof Error ? err.message : t("errors.loadFailed")); } finally { setLoading(false); @@ -1114,6 +1407,15 @@ export default function RunDetailPage({ + +