feat(awooop): surface alert automation blocker map
Some checks failed
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m40s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-26 08:56:13 +08:00
parent 75c9314528
commit 948004736a
3 changed files with 456 additions and 0 deletions

View File

@@ -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 沉澱矩陣",

View File

@@ -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 沉澱矩陣",

View File

@@ -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<AutomationAssetLedgerItem, "ready" | "pending" |
return "watching";
}
function statusLooksReady(status: string | null | undefined) {
const value = String(status ?? "").toLowerCase();
return Boolean(value) && !value.startsWith("blocked") && !value.includes("missing");
}
function blockedFromReady(total: number, ready: number) {
return Math.max(0, total - Math.max(0, Math.min(total, ready)));
}
function laneToneFromCounts(ready: number, total: number, blocked: number): WorkStatus {
if (blocked > 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 (
<section
className="border border-[#e0ddd4] bg-white"
data-testid="awooop-automation-blocker-map"
>
<div className="grid gap-px bg-[#e0ddd4] xl:grid-cols-[0.74fr_1.26fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]">
<TriangleAlert className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#9f2f25]">
{t("eyebrow")}
</p>
<h3 className="mt-1 text-lg font-semibold tracking-normal text-[#141413]">
{t("title")}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#5f5b52]">
{t("subtitle")}
</p>
</div>
</div>
<div className="mt-4 grid gap-px bg-[#e0ddd4] sm:grid-cols-4">
{[
{ key: "manual", value: manualGate },
{ key: "gap", value: automationGap },
{ key: "verified", value: verified },
{ key: "runtime", value: runtimeGate },
].map((metric) => (
<div key={metric.key} className="min-w-0 bg-[#faf9f3] px-3 py-2">
<p className="text-[11px] font-semibold text-[#77736a]">
{t(`metrics.${metric.key}` as never)}
</p>
<p className="mt-1 font-mono text-xl font-semibold text-[#141413]">
{loading ? "--" : formatCount(metric.value)}
</p>
</div>
))}
</div>
<div className="mt-4 border border-[#e0ddd4] bg-[#faf9f3] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-semibold text-[#141413]">{t("completion")}</p>
<span className="font-mono text-xs font-semibold text-[#9f2f25]">
{loading ? "--" : `${completion}% · ${ready}/${total}`}
</span>
</div>
<div className="mt-2 h-2 bg-white">
<div
className="h-full bg-[#d97757]"
style={{ width: `${Math.min(100, completion)}%` }}
/>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t("boundary", { blocked })}
</p>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{(topBlocked.length > 0 ? topBlocked : lanes.slice(0, 3)).map((lane) => (
<div key={lane.key} className="min-w-0 border border-[#e0ddd4] bg-white px-3 py-2">
<p className="text-[11px] font-semibold text-[#77736a]">
{t(`lanes.${lane.key}.title` as never)}
</p>
<p className="mt-1 font-mono text-lg font-semibold text-[#141413]">
{loading ? "--" : `${lane.ready}/${lane.total}`}
</p>
<p className="mt-1 truncate text-[11px] text-[#5f5b52]">
{t("blockedLabel", { count: lane.blocked })}
</p>
</div>
))}
</div>
</div>
<div className="min-w-0 bg-white p-4">
<div className="grid gap-2">
{lanes.map((lane, index) => {
const Icon = lane.icon;
const percent = lane.total > 0 ? Math.round((lane.ready / lane.total) * 100) : 0;
return (
<article
key={lane.key}
className="grid gap-3 border border-[#e0ddd4] bg-[#faf9f3] p-3 md:grid-cols-[minmax(0,0.72fr)_minmax(180px,0.28fr)]"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-semibold text-[#77736a]">
{String(index + 1).padStart(2, "0")}
</span>
<Icon className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">
{t(`lanes.${lane.key}.title` as never)}
</h4>
<span
className={cn(
"border px-1.5 py-0.5 text-[10px] font-semibold",
lane.tone === "blocked"
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: lane.tone === "live"
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: lane.tone === "in_progress"
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
: "border-[#d8d3c7] bg-white text-[#5f5b52]"
)}
>
{t(`tones.${lane.tone}` as never)}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t(`lanes.${lane.key}.detail` as never)}
</p>
<div className="mt-2 min-w-0 break-all font-mono text-[11px] leading-4 text-[#77736a]">
{lane.primaryRef}
</div>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-lg font-semibold text-[#141413]">
{loading ? "--" : `${lane.ready}/${lane.total}`}
</span>
<span className="text-[11px] font-semibold text-[#9f2f25]">
{t("blockedLabel", { count: lane.blocked })}
</span>
</div>
<div className="mt-2 h-2 bg-white">
<div
className={cn(
"h-full",
lane.tone === "live" ? "bg-[#78a87f]" : "bg-[#d97757]"
)}
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>
<p className="mt-2 break-all text-[11px] leading-4 text-[#5f5b52]">
{t("nextAction", { value: lane.nextAction })}
</p>
</div>
</article>
);
})}
</div>
</div>
</div>
</section>
);
}
function AutomationAssetLedgerPanel({
telemetry,
focusedDraft,
@@ -7247,6 +7595,12 @@ export default function AwoooPWorkItemsPage() {
loading={loading}
/>
<AutomationBlockerMapPanel
telemetry={telemetry}
focusedDraft={focusedRepairCandidateDraft}
loading={loading}
/>
<div className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-4">
{summary.map((item) => (
<SummaryCell key={item.label} label={item.label} value={item.value} icon={item.icon} />