fix(web): surface incident audit chain in work items
Some checks failed
CD Pipeline / tests (push) Successful in 1m31s
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled

This commit is contained in:
Your Name
2026-05-31 18:38:07 +08:00
parent d996426337
commit e6a433da22
4 changed files with 372 additions and 0 deletions

View File

@@ -3044,6 +3044,39 @@
"handoffStatus": "交接狀態:{status}"
}
},
"incidentAudit": {
"title": "焦點事件稽核鏈",
"emptyIncident": "尚未選到 Incident",
"empty": "目前工作項尚未連到 Incident先從重複告警或補救佇列選取工作項。",
"openRuns": "回 Run 監控",
"flowTitle": "處理流程",
"loading": "正在讀取 incident timeline先顯示焦點事件與等待資料。",
"timelineEmpty": "Incident timeline 尚未回應,不能判定流程階段。",
"evidenceTitle": "執行與學習證據",
"executor": "Executor",
"ansible": "Ansible / PlayBook",
"mcp": "MCP 調查",
"km": "KM / Learning",
"metrics": {
"stages": "階段",
"events": "事件",
"source": "Direct / Candidate / Applied",
"verification": "最終驗證"
},
"statusLabels": {
"success": "成功",
"completed": "已完成",
"warning": "警告",
"warn": "警告",
"failed": "失敗",
"error": "錯誤",
"blocked": "阻塞",
"pending": "等待中",
"info": "資訊",
"skipped": "已略過",
"unknown": "未知"
}
},
"recurrence": {
"title": "重複告警工作項",
"subtitle": "把 run_completed_no_repair、修復失敗與人工閘門接成可追蹤 work item",

View File

@@ -3044,6 +3044,39 @@
"handoffStatus": "交接狀態:{status}"
}
},
"incidentAudit": {
"title": "焦點事件稽核鏈",
"emptyIncident": "尚未選到 Incident",
"empty": "目前工作項尚未連到 Incident先從重複告警或補救佇列選取工作項。",
"openRuns": "回 Run 監控",
"flowTitle": "處理流程",
"loading": "正在讀取 incident timeline先顯示焦點事件與等待資料。",
"timelineEmpty": "Incident timeline 尚未回應,不能判定流程階段。",
"evidenceTitle": "執行與學習證據",
"executor": "Executor",
"ansible": "Ansible / PlayBook",
"mcp": "MCP 調查",
"km": "KM / Learning",
"metrics": {
"stages": "階段",
"events": "事件",
"source": "Direct / Candidate / Applied",
"verification": "最終驗證"
},
"statusLabels": {
"success": "成功",
"completed": "已完成",
"warning": "警告",
"warn": "警告",
"failed": "失敗",
"error": "錯誤",
"blocked": "阻塞",
"pending": "等待中",
"info": "資訊",
"skipped": "已略過",
"unknown": "未知"
}
},
"recurrence": {
"title": "重複告警工作項",
"subtitle": "把 run_completed_no_repair、修復失敗與人工閘門接成可追蹤 work item",

View File

@@ -922,9 +922,42 @@ type Telemetry = {
driftFingerprintState: DriftFingerprintState | null;
callbackReplies: CallbackRepliesWorkItemResponse | null;
statusChain: AwoooPStatusChain | null;
incidentTimeline: IncidentTimelineResponse | null;
aiRouteStatus: AiRouteStatusResponse | null;
};
type IncidentTimelineEvent = {
stage: string;
status: string;
title: string;
description?: string | null;
actor?: string | null;
timestamp?: string | null;
source_table?: string | null;
data?: Record<string, unknown>;
};
type IncidentTimelineStage = IncidentTimelineEvent & {
label: string;
events?: IncidentTimelineEvent[];
};
type 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>;
};
type WorkItem = {
id: string;
phase: string;
@@ -1043,6 +1076,37 @@ function selectStatusChainIncidentId(
);
}
function auditStatusClass(status?: string | null) {
if (status === "success" || status === "completed" || status === "resolved") {
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
}
if (status === "warning" || status === "warn" || status === "pending" || status === "info") {
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
if (status === "failed" || status === "error" || status === "blocked") {
return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
}
return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
}
function auditStatusLabelKey(status?: string | null) {
if (
status === "success" ||
status === "completed" ||
status === "warning" ||
status === "warn" ||
status === "failed" ||
status === "error" ||
status === "blocked" ||
status === "pending" ||
status === "info" ||
status === "skipped"
) {
return status;
}
return "unknown";
}
function recurrenceRepairStatusKey(status?: string | null) {
if (
status === "auto_repair_verified" ||
@@ -2380,6 +2444,178 @@ function ProductionClaimBanner({
);
}
function WorkItemIncidentAuditPanel({
timeline,
chain,
focusedIncidentId,
projectId,
loading,
}: {
timeline: IncidentTimelineResponse | null;
chain: AwoooPStatusChain | null;
focusedIncidentId: string | null;
projectId: string;
loading: boolean;
}) {
const t = useTranslations("awooop.workItems.incidentAudit");
const incidentId = focusedIncidentId ?? chain?.source_id ?? timeline?.incident_id ?? null;
const stages = timeline?.timeline?.filter((stage) => stage.status !== "skipped") ?? [];
const executor = timeline?.timeline?.find((stage) => stage.stage === "executor");
const verifier = timeline?.timeline?.find((stage) => stage.stage === "verifier");
const km = timeline?.timeline?.find((stage) => stage.stage === "km");
const investigator = timeline?.timeline?.find((stage) => stage.stage === "investigator");
const sourceCorrelation = chain?.source_refs?.correlation;
const ansible = chain?.execution?.ansible;
const timelineLoaded = Boolean(timeline);
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 === "executor" ||
event.stage === "verifier" ||
event.stage === "km" ||
event.stage === "ai_router"
))
.slice(-5)
.reverse();
return (
<section className="border border-[#e0ddd4] bg-white" aria-busy={loading && !timelineLoaded}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<SearchCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 truncate font-mono text-xs text-[#77736a]">
{incidentId ?? t("emptyIncident")}
</p>
</div>
</div>
<Link
href={`/awooop/runs?project_id=${encodeURIComponent(projectId)}` as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
{t("openRuns")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
{!incidentId ? (
<div className="px-4 py-5 text-sm leading-6 text-[#77736a]">
{t("empty")}
</div>
) : (
<>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("metrics.stages")}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{timelineLoaded ? stages.length : "--"}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("metrics.events")}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{timelineLoaded ? timeline?.events?.length ?? 0 : "--"}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("metrics.source")}</p>
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]">
{sourceCorrelation
? `${sourceCorrelation.direct_ref_total ?? 0}/${sourceCorrelation.candidate_total ?? 0}/${sourceCorrelation.applied_link_total ?? 0}`
: "--"}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("metrics.verification")}</p>
<span className={cn("mt-2 inline-flex border px-2 py-0.5 text-xs font-semibold", auditStatusClass(verifier?.status ?? chain?.verification))}>
{t(`statusLabels.${auditStatusLabelKey(verifier?.status ?? chain?.verification)}` as never)}
</span>
</div>
</div>
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.15fr_0.85fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("flowTitle")}</h4>
</div>
{timeline?.ascii_timeline ? (
<p className="mt-3 break-words border border-[#eee9dd] bg-[#faf9f3] px-3 py-2 font-mono text-xs leading-6 text-[#5f5b52]">
{timeline.ascii_timeline}
</p>
) : (
<p className="mt-3 text-sm text-[#77736a]">
{loading && !timelineLoaded ? t("loading") : t("timelineEmpty")}
</p>
)}
{stages.length > 0 ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{stages.slice(0, 6).map((stage) => (
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-white 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", auditStatusClass(stage.status))}>
{t(`statusLabels.${auditStatusLabelKey(stage.status)}` as never)}
</span>
</div>
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={stage.title}>
{stage.title}
</p>
</div>
))}
</div>
) : null}
</div>
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("evidenceTitle")}</h4>
</div>
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4]">
{[
[t("executor"), executor?.title ?? chain?.execution?.latest_operation_type ?? "--"],
[t("ansible"), ansible?.latest_playbook_path ?? ansible?.latest_catalog_id ?? "--"],
[t("mcp"), investigator?.title ?? "--"],
[t("km"), km?.title ?? "--"],
].map(([label, value]) => (
<div key={label} className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={value}>
{value}
</p>
</div>
))}
</div>
{importantEvents.length > 0 ? (
<div className="mt-3 divide-y divide-[#eee9dd] border border-[#eee9dd]">
{importantEvents.map((event, index) => (
<div key={`${event.stage}-${event.timestamp}-${index}`} className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("border px-2 py-0.5 text-[11px] font-semibold", auditStatusClass(event.status))}>
{t(`statusLabels.${auditStatusLabelKey(event.status)}` as never)}
</span>
<span className="font-mono text-[11px] text-[#77736a]">{event.source_table ?? "--"}</span>
</div>
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={event.title}>
{event.title}
</p>
</div>
))}
</div>
) : null}
</div>
</div>
</>
)}
</section>
);
}
function RecurrenceWorkQueuePanel({
recurrence,
focusedWorkItemId,
@@ -4902,6 +5138,7 @@ export default function AwoooPWorkItemsPage() {
driftFingerprintState: null,
callbackReplies: null,
statusChain: null,
incidentTimeline: null,
aiRouteStatus: null,
});
const [loading, setLoading] = useState(true);
@@ -4980,6 +5217,13 @@ export default function AwoooPWorkItemsPage() {
12000
);
}
const timelineIncidentId = statusChain?.source_id ?? statusChainIncidentId;
const incidentTimeline = timelineIncidentId
? await fetchJson<IncidentTimelineResponse>(
`${API_BASE}/api/v1/incidents/${encodeURIComponent(timelineIncidentId)}/timeline`,
12000
)
: null;
setTelemetry({
quality,
@@ -4999,6 +5243,7 @@ export default function AwoooPWorkItemsPage() {
driftFingerprintState,
callbackReplies,
statusChain,
incidentTimeline,
aiRouteStatus,
});
setLastUpdated(new Date());
@@ -5108,6 +5353,14 @@ export default function AwoooPWorkItemsPage() {
<AwoooPStatusChainPanel chain={telemetry.statusChain} />
<WorkItemIncidentAuditPanel
timeline={telemetry.incidentTimeline}
chain={telemetry.statusChain}
focusedIncidentId={focusedIncidentId}
projectId={projectId}
loading={loading}
/>
<RecurrenceWorkQueuePanel
recurrence={telemetry.eventRecurrence}
focusedWorkItemId={focusedWorkItemId}