fix(web): add incident audit timeline to run detail
Some checks failed
CD Pipeline / tests (push) Successful in 1m19s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 5m44s
CD Pipeline / post-deploy-checks (push) Failing after 30s

This commit is contained in:
Your Name
2026-05-31 17:57:47 +08:00
parent 716ed5a77c
commit bdcb059444
3 changed files with 352 additions and 0 deletions

View File

@@ -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": "警告",

View File

@@ -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": "警告",

View File

@@ -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<string, unknown>;
}
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<string, unknown>;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const AUTO_REFRESH_INTERVAL = 30_000;
@@ -275,8 +308,10 @@ const STATUS_STYLE: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, unknown> | 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 (
<section className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="h-5 w-48 animate-pulse bg-[#f2efe6]" />
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="bg-white px-4 py-3">
<div className="h-4 w-24 animate-pulse bg-[#f2efe6]" />
<div className="mt-2 h-7 w-16 animate-pulse bg-[#f2efe6]" />
</div>
))}
</div>
</section>
);
}
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2">
<FileSearch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 font-mono text-xs text-[#77736a]">{incidentId ?? emptyLabel}</p>
</div>
</div>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", timeline ? statusClass("received") : statusClass("pending"))}>
{timeline ? t("status.linked") : t("status.empty")}
</span>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{metrics.map((item) => (
<div key={item.label} className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 truncate font-mono text-xl font-semibold text-[#141413]" title={String(item.value)}>
{item.value}
</p>
</div>
))}
</div>
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1fr_1fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<Route className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("stagesTitle")}</h4>
</div>
{stages.length > 0 ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{stages.slice(0, 10).map((stage) => (
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-semibold text-[#77736a]">{stage.label}</span>
<span className={cn("shrink-0 border px-2 py-0.5 text-[11px] font-semibold", statusClass(stage.status))}>
{statusLabel(stage.status)}
</span>
</div>
<p className="mt-2 truncate text-sm font-semibold text-[#141413]" title={stage.title}>
{stage.title}
</p>
<p className="mt-1 truncate font-mono text-xs text-[#77736a]" title={stage.source_table ?? emptyLabel}>
{stage.source_table ?? emptyLabel}
</p>
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-[#77736a]">{t("empty")}</p>
)}
{timeline?.ascii_timeline && (
<p className="mt-3 break-words border border-[#eee9dd] bg-white px-3 py-2 font-mono text-xs leading-5 text-[#5f5b52]">
{timeline.ascii_timeline}
</p>
)}
</div>
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<SearchCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("matchingTitle")}</h4>
</div>
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-3">
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("playbook")}</p>
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={selectedPlaybook}>{selectedPlaybook}</p>
</div>
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("executor")}</p>
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={executor?.title ?? emptyLabel}>{executor?.status ?? emptyLabel}</p>
</div>
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("km")}</p>
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={kmStage?.title ?? emptyLabel}>{kmStage?.status ?? emptyLabel}</p>
</div>
</div>
{candidates.length > 0 ? (
<div className="mt-3 divide-y divide-[#eee9dd] border border-[#eee9dd]">
{candidates.slice(0, 3).map((candidate) => (
<div key={`${candidate.provider}:${candidate.provider_event_id}`} className="grid gap-2 px-3 py-3 md:grid-cols-[96px_1fr]">
<span className="font-mono text-xs font-semibold text-[#5f5b52]">{candidate.provider ?? emptyLabel}</span>
<div className="min-w-0">
<p className="truncate font-mono text-xs font-semibold text-[#141413]" title={candidate.provider_event_id ?? emptyLabel}>
{candidate.provider_event_id ?? emptyLabel}
</p>
<p className="mt-1 truncate text-xs text-[#77736a]" title={(candidate.reasons ?? []).join(", ")}>
{t("candidateDetail", {
score: candidate.score ?? 0,
state: candidate.link_state ?? emptyLabel,
reasons: (candidate.reasons ?? []).join(", ") || emptyLabel,
})}
</p>
</div>
</div>
))}
</div>
) : (
<p className="mt-3 border border-[#eee9dd] bg-[#faf9f3] px-3 py-3 text-sm leading-6 text-[#5f5b52]">
{t("matchingEmpty", { reason: sourceReason })}
</p>
)}
</div>
</div>
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-2">
<div className="flex items-center gap-2">
<BookOpenCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("eventsTitle")}</h4>
</div>
</div>
{importantEvents.length > 0 ? (
<div className="divide-y divide-[#eee9dd]">
{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 (
<article key={`${event.stage}-${event.timestamp}-${index}`} className="grid gap-3 px-4 py-3 md:grid-cols-[132px_1fr]">
<div className="font-mono text-xs text-[#77736a]">
{formatTime(event.timestamp, locale, emptyLabel)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass(event.status))}>
{statusLabel(event.status)}
</span>
<span className="font-mono text-xs text-[#77736a]">{event.source_table ?? emptyLabel}</span>
</div>
<p className="mt-2 break-words text-sm font-semibold text-[#141413]">{event.title}</p>
{event.description && (
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[#5f5b52]">{event.description}</p>
)}
{(primaryMeta || secondaryMeta) && (
<p className="mt-2 truncate font-mono text-xs text-[#77736a]" title={[primaryMeta, secondaryMeta].filter(Boolean).join(" / ")}>
{[primaryMeta, secondaryMeta].filter(Boolean).join(" / ")}
</p>
)}
</div>
</article>
);
})}
</div>
) : (
<div className="px-4 py-8 text-sm text-[#77736a]">{t("eventsEmpty")}</div>
)}
</section>
);
}
function TimelineRow({
item,
locale,
@@ -973,6 +1245,8 @@ export default function RunDetailPage({
const [detail, setDetail] = useState<RunDetailResponse | null>(null);
const [dossier, setDossier] = useState<ChannelEventDossierResponse | null>(null);
const [incidentTimeline, setIncidentTimeline] = useState<IncidentTimelineResponse | null>(null);
const [incidentTimelineLoading, setIncidentTimelineLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(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({
<AwoooPStatusChainPanel chain={detail?.awooop_status_chain} />
<IncidentAuditTimelinePanel
timeline={incidentTimeline}
chain={detail?.awooop_status_chain}
loading={incidentTimelineLoading}
locale={locale}
emptyLabel={t("empty")}
statusLabel={statusLabel}
/>
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
<OwnerResponseValidationDetailBoundaryPanel />