From 948004736a9931f8b38bc53a60c5a1cc6566d656 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 08:56:13 +0800 Subject: [PATCH] feat(awooop): surface alert automation blocker map --- apps/web/messages/en.json | 51 +++ apps/web/messages/zh-TW.json | 51 +++ .../app/[locale]/awooop/work-items/page.tsx | 354 ++++++++++++++++++ 3 files changed, 456 insertions(+) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index be9694c19..9af88acf4 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -8623,6 +8623,57 @@ } } }, + "automationBlockerMap": { + "eyebrow": "告警自動化卡點總盤", + "title": "為什麼仍需人工處理", + "subtitle": "把焦點事故從收件、證據、候選、PlayBook、安全路由、放行、Verifier 到學習回寫拆成可量化 lane;先看卡在哪裡,再看下方詳細證據。", + "completion": "自動化閉環就緒度", + "boundary": "目前仍有 {blocked} 個阻擋點;這是只讀判讀,不代表 runtime gate 已開或可直接套用修復。", + "blockedLabel": "卡點 {count}", + "nextAction": "下一步:{value}", + "metrics": { + "manual": "人工閘門", + "gap": "自動化缺口", + "verified": "已驗證修復", + "runtime": "Runtime gate" + }, + "tones": { + "live": "就緒", + "in_progress": "補齊中", + "blocked": "卡住", + "watching": "觀察" + }, + "lanes": { + "evidence": { + "title": "證據收斂", + "detail": "確認 MCP、timeline、來源關聯是否足以支撐 AI 判斷。" + }, + "candidate": { + "title": "修復候選", + "detail": "確認是否已產生可審查的候選、草案或 dry-run candidate。" + }, + "playbook": { + "title": "PlayBook / Ansible", + "detail": "確認服務專屬 PlayBook、catalog 與信任路徑,不把診斷命令當修復。" + }, + "safeRoute": { + "title": "安全路由", + "detail": "確認 allowlisted route、check-mode、apply path 與禁止事項。" + }, + "release": { + "title": "執行放行合約", + "detail": "確認 owner 放行、維護窗口、rollback、blast radius 與 write 邊界。" + }, + "verifier": { + "title": "套用後 Verifier", + "detail": "確認修復後驗證計畫與結果回寫,不以看起來正常當成功。" + }, + "learning": { + "title": "KM / Trust 回寫", + "detail": "確認 KM、PlayBook trust 與學習責任已沉澱,避免下次重複人工判斷。" + } + } + }, "assetLedger": { "eyebrow": "自動化資產總帳", "title": "KM / PlayBook / 腳本 / 排程 / Verifier 沉澱矩陣", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index be9694c19..9af88acf4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -8623,6 +8623,57 @@ } } }, + "automationBlockerMap": { + "eyebrow": "告警自動化卡點總盤", + "title": "為什麼仍需人工處理", + "subtitle": "把焦點事故從收件、證據、候選、PlayBook、安全路由、放行、Verifier 到學習回寫拆成可量化 lane;先看卡在哪裡,再看下方詳細證據。", + "completion": "自動化閉環就緒度", + "boundary": "目前仍有 {blocked} 個阻擋點;這是只讀判讀,不代表 runtime gate 已開或可直接套用修復。", + "blockedLabel": "卡點 {count}", + "nextAction": "下一步:{value}", + "metrics": { + "manual": "人工閘門", + "gap": "自動化缺口", + "verified": "已驗證修復", + "runtime": "Runtime gate" + }, + "tones": { + "live": "就緒", + "in_progress": "補齊中", + "blocked": "卡住", + "watching": "觀察" + }, + "lanes": { + "evidence": { + "title": "證據收斂", + "detail": "確認 MCP、timeline、來源關聯是否足以支撐 AI 判斷。" + }, + "candidate": { + "title": "修復候選", + "detail": "確認是否已產生可審查的候選、草案或 dry-run candidate。" + }, + "playbook": { + "title": "PlayBook / Ansible", + "detail": "確認服務專屬 PlayBook、catalog 與信任路徑,不把診斷命令當修復。" + }, + "safeRoute": { + "title": "安全路由", + "detail": "確認 allowlisted route、check-mode、apply path 與禁止事項。" + }, + "release": { + "title": "執行放行合約", + "detail": "確認 owner 放行、維護窗口、rollback、blast radius 與 write 邊界。" + }, + "verifier": { + "title": "套用後 Verifier", + "detail": "確認修復後驗證計畫與結果回寫,不以看起來正常當成功。" + }, + "learning": { + "title": "KM / Trust 回寫", + "detail": "確認 KM、PlayBook trust 與學習責任已沉澱,避免下次重複人工判斷。" + } + } + }, "assetLedger": { "eyebrow": "自動化資產總帳", "title": "KM / PlayBook / 腳本 / 排程 / Verifier 沉澱矩陣", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index b57d21cec..868386a56 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1047,6 +1047,26 @@ type AutomationAssetLedgerItem = { primaryRef: string; }; +type AutomationBlockerLaneKey = + | "evidence" + | "candidate" + | "playbook" + | "safeRoute" + | "release" + | "verifier" + | "learning"; + +type AutomationBlockerLane = { + key: AutomationBlockerLaneKey; + icon: typeof Activity; + tone: WorkStatus; + ready: number; + total: number; + blocked: number; + primaryRef: string; + nextAction: string; +}; + const toCount = (value: number | null | undefined) => Number.isFinite(value) && value ? Math.max(0, Math.trunc(value)) : 0; @@ -1066,6 +1086,152 @@ function ledgerTone(item: Pick 0) return "blocked"; + if (ready > 0 && ready < total) return "in_progress"; + if (total > 0 && ready >= total) return "live"; + return "watching"; +} + +function buildAutomationBlockerLanes( + telemetry: Telemetry, + focusedDraft: RepairCandidateDraftFocus | null +): AutomationBlockerLane[] { + const chain = telemetry.statusChain; + const closure = chain?.automation_handoff?.closure_readiness; + const preflight = closure?.controlled_execution_preflight; + const release = closure?.execution_release_contract; + const ansible = chain?.execution?.ansible; + const playbookCount = (chain?.execution?.playbook_ids?.length ?? 0) + + (chain?.execution?.playbook_paths?.length ?? 0); + const mcpTotal = toCount(chain?.mcp?.gateway?.total ?? chain?.evidence?.mcp_gateway_total); + const timelineTotal = toCount(telemetry.incidentTimeline?.events?.length); + const sourceTotal = toCount(chain?.source_refs?.correlation?.provider_event_total) + + toCount(chain?.source_refs?.correlation?.direct_ref_total) + + toCount(chain?.source_refs?.correlation?.candidate_total) + + toCount(chain?.source_refs?.correlation?.applied_link_total); + const checkModeTotal = toCount(ansible?.check_mode_total) + toCount(ansible?.pending_check_mode_total); + const ansibleCandidateCount = toCount(ansible?.candidate_count); + const releaseFieldReady = (key: string) => { + const field = release?.fields?.find((item) => item.key === key); + return statusLooksReady(field?.status) ? 1 : 0; + }; + const releaseNextAction = release?.next_steps?.map((item) => item.work_item_id).filter(Boolean)[0] ?? chain?.next_step ?? "--"; + const sourceCorrelationStatus = chain?.source_refs?.correlation?.status ?? chain?.source_refs?.correlation?.verification_status ?? "--"; + const evidenceReady = (mcpTotal > 0 ? 1 : 0) + + (timelineTotal > 0 ? 1 : 0) + + (sourceTotal > 0 ? 1 : 0); + const candidateReady = (focusedDraft ? 1 : 0) + + (chain?.automation_handoff?.candidate || release?.route_id ? 1 : 0) + + (ansibleCandidateCount > 0 ? 1 : 0) + + (checkModeTotal > 0 ? 1 : 0); + const playbookReady = (playbookCount > 0 ? 1 : 0) + + (ansibleCandidateCount > 0 ? 1 : 0) + + (chain?.automation_handoff?.candidate?.catalog_id ? 1 : 0); + const verifierReady = releaseFieldReady("post_apply_verifier") + + (String(chain?.verification ?? "").toLowerCase().includes("verified") ? 1 : 0) + + (toCount(closure?.release_verifier_package?.post_release_verifier_ready_count) > 0 ? 1 : 0); + const learningReady = (toCount(chain?.evidence?.knowledge_entries) > 0 ? 1 : 0) + + releaseFieldReady("km_writeback_owner") + + releaseFieldReady("playbook_trust_owner"); + const lanes: AutomationBlockerLane[] = [ + { + key: "evidence", + icon: Fingerprint, + ready: evidenceReady, + total: 3, + blocked: blockedFromReady(3, evidenceReady), + primaryRef: `MCP ${mcpTotal} / timeline ${timelineTotal} / source ${sourceTotal}`, + nextAction: String(sourceCorrelationStatus), + tone: laneToneFromCounts(evidenceReady, 3, blockedFromReady(3, evidenceReady)), + }, + { + key: "candidate", + icon: SearchCheck, + ready: candidateReady, + total: 4, + blocked: blockedFromReady(4, candidateReady), + primaryRef: focusedDraft?.workItemId ?? chain?.automation_handoff?.candidate?.catalog_id ?? release?.route_id ?? "--", + nextAction: String(chain?.next_step ?? "--"), + tone: laneToneFromCounts(candidateReady, 4, blockedFromReady(4, candidateReady)), + }, + { + key: "playbook", + icon: FileText, + ready: playbookReady, + total: 3, + blocked: blockedFromReady(3, playbookReady), + primaryRef: chain?.execution?.playbook_paths?.[0] ?? chain?.automation_handoff?.candidate?.apply_playbook_path ?? "--", + nextAction: String(ansible?.not_used_reason ?? chain?.operator_outcome?.next_action ?? "--"), + tone: laneToneFromCounts(playbookReady, 3, blockedFromReady(3, playbookReady)), + }, + { + key: "safeRoute", + icon: Network, + ready: toCount(preflight?.ready_count), + total: toCount(preflight?.total_count) || 7, + blocked: toCount(preflight?.blocked_count) || blockedFromReady(7, toCount(preflight?.ready_count)), + primaryRef: preflight?.routes?.[0]?.route_id ?? release?.route_id ?? "--", + nextAction: String(preflight?.blocked_reason ?? preflight?.next_action ?? "--"), + tone: laneToneFromCounts( + toCount(preflight?.ready_count), + toCount(preflight?.total_count) || 7, + toCount(preflight?.blocked_count) || blockedFromReady(7, toCount(preflight?.ready_count)) + ), + }, + { + key: "release", + icon: ShieldCheck, + ready: toCount(release?.ready_count), + total: toCount(release?.total_count) || 11, + blocked: toCount(release?.blocked_count) || blockedFromReady(11, toCount(release?.ready_count)), + primaryRef: release?.work_item_id ?? "--", + nextAction: String(release?.blocked_reason ?? releaseNextAction), + tone: laneToneFromCounts( + toCount(release?.ready_count), + toCount(release?.total_count) || 11, + toCount(release?.blocked_count) || blockedFromReady(11, toCount(release?.ready_count)) + ), + }, + { + key: "verifier", + icon: CheckCircle2, + ready: verifierReady, + total: 3, + blocked: blockedFromReady(3, verifierReady), + primaryRef: release?.fields?.find((item) => item.key === "post_apply_verifier")?.value + ?? chain?.automation_handoff?.asset_ids?.verifier + ?? "--", + nextAction: String(chain?.verification ?? closure?.release_verifier_package?.status ?? "--"), + tone: laneToneFromCounts(verifierReady, 3, blockedFromReady(3, verifierReady)), + }, + { + key: "learning", + icon: Database, + ready: learningReady, + total: 3, + blocked: blockedFromReady(3, learningReady), + primaryRef: `KM ${toCount(chain?.evidence?.knowledge_entries)} / PlayBook ${playbookCount}`, + nextAction: String(release?.fields?.find((item) => item.key === "km_writeback_owner")?.next_step ?? chain?.operator_outcome?.next_action ?? "--"), + tone: laneToneFromCounts(learningReady, 3, blockedFromReady(3, learningReady)), + }, + ]; + return lanes.map((lane) => ({ + ...lane, + ready: Math.max(0, Math.min(lane.total, lane.ready)), + blocked: Math.max(0, Math.min(lane.total, lane.blocked)), + })); +} + function buildAutomationAssetLedger( telemetry: Telemetry, focusedDraft: RepairCandidateDraftFocus | null @@ -3408,6 +3574,188 @@ function OperatorSopRail({ ); } +function AutomationBlockerMapPanel({ + telemetry, + focusedDraft, + loading, +}: { + telemetry: Telemetry; + focusedDraft: RepairCandidateDraftFocus | null; + loading: boolean; +}) { + const t = useTranslations("awooop.workItems.automationBlockerMap"); + const locale = useLocale(); + const lanes = useMemo( + () => buildAutomationBlockerLanes(telemetry, focusedDraft), + [focusedDraft, telemetry] + ); + const ready = lanes.reduce((sum, lane) => sum + lane.ready, 0); + const total = lanes.reduce((sum, lane) => sum + lane.total, 0); + const blocked = lanes.reduce((sum, lane) => sum + lane.blocked, 0); + const completion = total > 0 ? Math.round((ready / total) * 100) : 0; + const manualGate = toCount(telemetry.eventRecurrence?.summary?.manual_gate_group_total); + const automationGap = toCount(telemetry.eventRecurrence?.summary?.automation_gap_group_total); + const verified = toCount(telemetry.quality?.verified_auto_repair_total); + const runtimeGate = toCount(telemetry.reportSourceHealth?.rollups?.runtime_gate_count); + const topBlocked = [...lanes] + .filter((lane) => lane.blocked > 0) + .sort((a, b) => b.blocked - a.blocked) + .slice(0, 3); + const formatCount = useCallback( + (value: number) => value.toLocaleString(locale === "zh-TW" ? "zh-TW" : "en-US"), + [locale] + ); + + return ( +
+
+
+
+
+
+
+

+ {t("eyebrow")} +

+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+
+ +
+ {[ + { key: "manual", value: manualGate }, + { key: "gap", value: automationGap }, + { key: "verified", value: verified }, + { key: "runtime", value: runtimeGate }, + ].map((metric) => ( +
+

+ {t(`metrics.${metric.key}` as never)} +

+

+ {loading ? "--" : formatCount(metric.value)} +

+
+ ))} +
+ +
+
+

{t("completion")}

+ + {loading ? "--" : `${completion}% · ${ready}/${total}`} + +
+
+
+
+

+ {t("boundary", { blocked })} +

+
+ +
+ {(topBlocked.length > 0 ? topBlocked : lanes.slice(0, 3)).map((lane) => ( +
+

+ {t(`lanes.${lane.key}.title` as never)} +

+

+ {loading ? "--" : `${lane.ready}/${lane.total}`} +

+

+ {t("blockedLabel", { count: lane.blocked })} +

+
+ ))} +
+
+ +
+
+ {lanes.map((lane, index) => { + const Icon = lane.icon; + const percent = lane.total > 0 ? Math.round((lane.ready / lane.total) * 100) : 0; + return ( +
+
+
+ + {String(index + 1).padStart(2, "0")} + +
+

+ {t(`lanes.${lane.key}.detail` as never)} +

+
+ {lane.primaryRef} +
+
+
+
+ + {loading ? "--" : `${lane.ready}/${lane.total}`} + + + {t("blockedLabel", { count: lane.blocked })} + +
+
+
+
+

+ {t("nextAction", { value: lane.nextAction })} +

+
+
+ ); + })} +
+
+
+
+ ); +} + function AutomationAssetLedgerPanel({ telemetry, focusedDraft, @@ -7247,6 +7595,12 @@ export default function AwoooPWorkItemsPage() { loading={loading} /> + +
{summary.map((item) => (