fix(web): surface incident audit chain in work items
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
## 2026-05-31|Work Items 焦點 Incident 稽核鏈 local 收斂
|
||||
|
||||
**背景**:
|
||||
|
||||
- Run detail 已可顯示單一 Incident 的稽核時間線,但操作員在 `AwoooP / Work Items` 仍需要同一條鏈路:Telegram / Run / Approval / Work Item 必須能對到同一個 Incident、同一組流程階段與同一份執行證據。
|
||||
- 使用者指出 Telegram 詳情與歷史若只顯示「需要人工 / blocked / warning」,仍無法判斷是否真的有 AI 自動判斷、MCP 調查、PlayBook / Ansible 執行、KM 寫入或 verifier 結果。
|
||||
|
||||
**本次調整**:
|
||||
|
||||
- `apps/web/src/app/[locale]/awooop/work-items/page.tsx`:
|
||||
- Work Items 讀取 `incident_id` query 或 recurrence/remediation 最新 Incident 後,串 `/api/v1/platform/status-chain` 與 `/api/v1/incidents/{incident_id}/timeline`。
|
||||
- 新增 `焦點事件稽核鏈` 面板,顯示階段數、事件數、Direct / Candidate / Applied、最終驗證、處理流程、Executor、Ansible / PlayBook、MCP 調查、KM / Learning。
|
||||
- 稽核事件收斂 `automation_operation_log`、`knowledge_entries`、`incident_evidence`、`alert_operation_log` 與 executor / verifier / km / ai_router 階段。
|
||||
- 讀取中不再顯示無語意骨架;會先顯示焦點 Incident、`正在讀取 incident timeline` 與明確的未知狀態,避免操作員誤判成前端漏接。
|
||||
- `apps/web/messages/zh-TW.json` / `apps/web/messages/en.json` 補齊 i18n。
|
||||
|
||||
**Local 驗證**:
|
||||
|
||||
```text
|
||||
python3 -m json.tool apps/web/messages/zh-TW.json -> pass
|
||||
python3 -m json.tool apps/web/messages/en.json -> pass
|
||||
git diff --check -> pass
|
||||
pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-work-items-incident-audit-20260531.tsbuildinfo -> pass
|
||||
python3 scripts/security/security-mirror-progress-guard.py -> SECURITY_MIRROR_PROGRESS_GUARD_OK
|
||||
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build -> pass
|
||||
```
|
||||
|
||||
**Browser smoke(local production build)**:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3107/zh-TW/awooop/work-items?project_id=awoooi&incident_id=INC-20260530-0DD83C
|
||||
visible: 焦點事件稽核鏈
|
||||
visible: 處理流程
|
||||
visible: 執行與學習證據
|
||||
visible: 正在讀取 incident timeline,先顯示焦點事件與等待資料
|
||||
canScroll=true
|
||||
horizontalOverflow=false
|
||||
```
|
||||
|
||||
**技術債 / 現場清理**:
|
||||
|
||||
- 本機 `/System/Volumes/Data` 一度只剩約 103MiB,導致 Git 無法寫入 `FETCH_HEAD`,且 build 先前出現 webpack cache ENOSPC 警告。
|
||||
- 已只清理本 worktree 產生物 `apps/web/.next`,釋放約 1.5GiB;未刪資料庫、原始碼或任何 production 狀態。
|
||||
- 後續仍應把 local runner / build cache 空間列為開發機維運項,避免前端驗證被快取空間污染。
|
||||
|
||||
**目前整體進度(local ready, pending Gitea deploy)**:
|
||||
|
||||
- Telegram / Run / Work Items 單一 Incident drill-down:約 88%;Run detail 已 production,Work Items 已 local 驗證,待推版與 production smoke。
|
||||
- MCP / Sentry / SigNoz / KM / PlayBook / Ansible 的跨頁透明度:約 84%;Work Items 已能承接同一條 timeline,但 Approvals / Tickets 還需同樣接入。
|
||||
- 前端 AI 自動化管理介面同步:約 85%;工作鏈路頁開始成為操作員入口,不再只靠 Telegram 按鈕。
|
||||
- 整體 AI 自動化飛輪:約 73%;仍不能宣稱 24h 全自動 repair 閉環,需以 production evidence 持續補齊。
|
||||
- 24h 完整 AI Agent 自動修復 production claim:0%;仍維持嚴格口徑,只能宣稱「已驗證的特定 controlled apply / drill-down 能被追蹤」。
|
||||
|
||||
## 2026-05-31|Ollama 111 local fallback 復原確認
|
||||
|
||||
**背景**:
|
||||
|
||||
Reference in New Issue
Block a user