feat(web): visualize awooop automation flow
Some checks failed
CD Pipeline / tests (push) Successful in 1m19s
Code Review / ai-code-review (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 3m54s
CD Pipeline / post-deploy-checks (push) Failing after 15m35s

This commit is contained in:
Your Name
2026-06-01 11:52:47 +08:00
parent d40cab8a8f
commit 4ee3998f03
3 changed files with 309 additions and 133 deletions

View File

@@ -4181,11 +4181,24 @@
"claimReady": "可以宣稱全自動修復",
"claimBlocked": "不可宣稱全自動修復",
"claimReason": "原因:{reason}",
"mapTitle": "Automation Flow Map",
"mapSubtitle": "依照真實 truth-chain gate把告警到 Operator 可見性切成 8 個節點。",
"statusCount": "{status} {count}",
"attentionTitle": "優先瓶頸",
"attentionAction": "下一步:{action}",
"heatmapTitle": "Gate Evidence Heatmap",
"bottleneckTitle": "目前瓶頸",
"coverage": "{percent}% 通過",
"counts": "pass {passed} / warn {warning} / miss {missing} / fail {failed}",
"nextAction": "下一步:{action}",
"example": "例:{incidentId} / {verdict}",
"sourceStatuses": "來源 Gate{statuses}",
"heatmap": {
"pass": "Pass",
"warn": "Warn",
"miss": "Miss",
"fail": "Fail"
},
"statuses": {
"passed": "Passed",
"warning": "Warning",
@@ -4204,6 +4217,7 @@
"verifiedRepairDetail": "只有修復成功且完成事後驗證才計入。",
"blockedGates": "Blocked Gate",
"blockedGatesDetail": "有 missing / failed 就不能宣稱完整自動化。",
"readiness": "流程健康度",
"warningGates": "Warning Gate",
"warningGatesDetail": "仍可觀測,但需要補脈絡或人工判斷。"
},

View File

@@ -4181,11 +4181,24 @@
"claimReady": "可以宣稱全自動修復",
"claimBlocked": "不可宣稱全自動修復",
"claimReason": "原因:{reason}",
"mapTitle": "Automation Flow Map",
"mapSubtitle": "依照真實 truth-chain gate把告警到可見性的流程畫成 8 個節點。",
"statusCount": "{status} {count}",
"attentionTitle": "優先處理",
"attentionAction": "下一步:{action}",
"heatmapTitle": "Gate Evidence Heatmap",
"bottleneckTitle": "目前瓶頸",
"coverage": "{percent}% 通過",
"counts": "pass {passed} / warn {warning} / miss {missing} / fail {failed}",
"nextAction": "下一步:{action}",
"example": "例:{incidentId} / {verdict}",
"sourceStatuses": "來源 Gate{statuses}",
"heatmap": {
"pass": "Pass",
"warn": "Warn",
"miss": "Miss",
"fail": "Fail"
},
"statuses": {
"passed": "Passed",
"warning": "Warning",
@@ -4204,6 +4217,7 @@
"verifiedRepairDetail": "只有修復成功且完成事後驗證才計入。",
"blockedGates": "Blocked Gate",
"blockedGatesDetail": "有 missing / failed 就不能宣稱完整自動化。",
"readiness": "流程健康度",
"warningGates": "Warning Gate",
"warningGatesDetail": "仍可觀測,但需要補脈絡或人工判斷。"
},

View File

@@ -2869,6 +2869,27 @@ function automationFlowStatusClass(status?: AutomationFlowStatus | null) {
return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
}
function automationFlowStatusFill(status?: AutomationFlowStatus | null) {
if (status === "passed") return "bg-[#4f9d5f]";
if (status === "warning") return "bg-[#c58a24]";
if (status === "blocked") return "bg-[#c65145]";
return "bg-[#b8b2a7]";
}
function automationFlowStatusSurface(status?: AutomationFlowStatus | null) {
if (status === "passed") return "border-[#9bc7a4] bg-[#f4fbf5]";
if (status === "warning") return "border-[#d9b36f] bg-[#fff8ea]";
if (status === "blocked") return "border-[#e2a29b] bg-[#fff5f3]";
return "border-[#d8d3c7] bg-[#faf9f3]";
}
function automationFlowStatusText(status?: AutomationFlowStatus | null) {
if (status === "passed") return "text-[#17602a]";
if (status === "warning") return "text-[#8a5a08]";
if (status === "blocked") return "text-[#9f2f25]";
return "text-[#5f5b52]";
}
function automationFlowStatusLabelKey(status?: AutomationFlowStatus | null) {
if (
status === "passed" ||
@@ -2911,6 +2932,34 @@ function AutomationFlowGatePanel({
const claimReasonLabel = claimReasonKey
? t(`claimReasons.${claimReasonKey}` as never)
: claimReason;
const gates = flow?.gates ?? [];
const readiness = gates.length
? Math.round(gates.reduce((acc, gate) => acc + (gate.passed_percent ?? 0), 0) / gates.length)
: 0;
const statusCounts = gates.reduce(
(acc, gate) => {
if (gate.status === "passed") acc.passed += 1;
else if (gate.status === "warning") acc.warning += 1;
else if (gate.status === "blocked") acc.blocked += 1;
return acc;
},
{ passed: 0, warning: 0, blocked: 0 }
);
const blockedGates = gates.filter((gate) => gate.status === "blocked");
const warningGates = gates.filter((gate) => gate.status === "warning");
const firstAttentionGate = blockedGates[0] ?? warningGates[0] ?? null;
const attentionGateLabelKey = firstAttentionGate
? AUTOMATION_FLOW_GATE_LABEL_KEYS[firstAttentionGate.gate]
: null;
const attentionActionKey = firstAttentionGate?.next_action
? AUTOMATION_FLOW_ACTION_KEYS[firstAttentionGate.next_action]
: null;
const attentionGateLabel = attentionGateLabelKey
? t(`gates.${attentionGateLabelKey}` as never)
: firstAttentionGate?.gate ?? "--";
const attentionActionLabel = attentionActionKey
? t(`actions.${attentionActionKey}` as never)
: firstAttentionGate?.next_action ?? "--";
return (
<section id="automation-flow-gates" className="border border-[#e0ddd4] bg-white">
@@ -2950,146 +2999,245 @@ function AutomationFlowGatePanel({
</div>
) : (
<>
<div
className={cn(
"border-b px-4 py-3 text-xs leading-5",
claimReady
? "border-[#c8dfcb] bg-[#f4fbf5] text-[#17602a]"
: "border-[#eed2ce] bg-[#fff6f5] text-[#8f2c22]"
)}
>
<div className="flex flex-wrap items-center gap-2 font-semibold">
{claimReady ? (
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
) : (
<AlertCircle className="h-4 w-4" aria-hidden="true" />
)}
<span>{claimReady ? t("claimReady") : t("claimBlocked")}</span>
</div>
<p className="mt-1">{t("claimReason", { reason: claimReasonLabel })}</p>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{[
{
label: t("metrics.evaluated"),
value: flow.evaluated_total,
detail: t("metrics.evaluatedDetail", {
incidents: summary.incident_total ?? 0,
}),
},
{
label: t("metrics.verifiedRepair"),
value: summary.verified_auto_repair_total ?? 0,
detail: t("metrics.verifiedRepairDetail"),
},
{
label: t("metrics.blockedGates"),
value: flow.blocked_gates?.length ?? 0,
detail: t("metrics.blockedGatesDetail"),
},
{
label: t("metrics.warningGates"),
value: flow.warning_gates?.length ?? 0,
detail: t("metrics.warningGatesDetail"),
},
].map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
</div>
))}
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{flow.gates.map((gate) => {
const StatusIcon = automationFlowStatusIcon(gate.status);
const gateLabelKey = AUTOMATION_FLOW_GATE_LABEL_KEYS[gate.gate];
const gateLabel = gateLabelKey
? t(`gates.${gateLabelKey}` as never)
: gate.gate;
const actionKey = gate.next_action
? AUTOMATION_FLOW_ACTION_KEYS[gate.next_action]
: null;
const actionLabel = actionKey
? t(`actions.${actionKey}` as never)
: gate.next_action ?? "--";
const passedPercent = Math.max(
0,
Math.min(100, Number.isFinite(gate.passed_percent) ? gate.passed_percent : 0)
);
const example = gate.examples?.[0];
const sourceStatuses = Object.entries(example?.source_statuses ?? {})
.map(([key, value]) => `${key}:${value}`)
.join(", ");
return (
<article key={gate.gate} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#141413]">{gateLabel}</p>
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
{gate.quality_gates?.join(" / ") || gate.gate}
</p>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 border px-2 py-0.5 text-xs font-semibold",
automationFlowStatusClass(gate.status)
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
{t(automationFlowStatusLabelKey(gate.status) as never)}
</span>
</div>
<div className="mt-3 h-1.5 bg-[#ece7dc]">
<div
className={cn(
"h-full",
gate.status === "passed"
? "bg-[#4f9d5f]"
: gate.status === "warning"
? "bg-[#c58a24]"
: "bg-[#c65145]"
)}
style={{ width: `${passedPercent}%` }}
/>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t("coverage", { percent: passedPercent.toFixed(1) })}
<div className="grid gap-px border-b border-[#e0ddd4] bg-[#e0ddd4] xl:grid-cols-[minmax(0,1.85fr)_minmax(330px,0.85fr)]">
<div className="bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#77736a]">
{t("mapTitle")}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t("counts", {
passed: gate.passed_total ?? 0,
warning: gate.warning_total ?? 0,
missing: gate.missing_total ?? 0,
failed: gate.failed_total ?? 0,
})}
{t("mapSubtitle")}
</p>
<p className="mt-2 text-xs font-semibold leading-5 text-[#141413]">
{t("nextAction", { action: actionLabel })}
</p>
{example ? (
<div className="mt-2 space-y-1 text-xs leading-5 text-[#77736a]">
<p>
{t("example", {
incidentId: example.incident_id ?? "--",
verdict: example.verdict ?? "--",
})}
</div>
<div className="flex flex-wrap gap-1.5 text-xs">
{(["passed", "warning", "blocked"] as const).map((status) => (
<span
key={status}
className={cn(
"inline-flex items-center gap-1 border px-2 py-1 font-semibold",
automationFlowStatusClass(status)
)}
>
<span className={cn("h-2 w-2", automationFlowStatusFill(status))} />
{t("statusCount", {
status: t(automationFlowStatusLabelKey(status) as never),
count: statusCounts[status],
})}
</span>
))}
</div>
</div>
<div className="mt-4 overflow-x-auto pb-1">
<div className="grid min-w-[920px] grid-cols-8 gap-px bg-[#d8d3c7]">
{gates.map((gate, index) => {
const StatusIcon = automationFlowStatusIcon(gate.status);
const gateLabelKey = AUTOMATION_FLOW_GATE_LABEL_KEYS[gate.gate];
const gateLabel = gateLabelKey
? t(`gates.${gateLabelKey}` as never)
: gate.gate;
const passedPercent = Math.max(
0,
Math.min(100, Number.isFinite(gate.passed_percent) ? gate.passed_percent : 0)
);
return (
<div
key={gate.gate}
className={cn(
"relative min-h-[150px] border p-3",
automationFlowStatusSurface(gate.status)
)}
>
{index < gates.length - 1 ? (
<ArrowRight
className="absolute -right-3 top-1/2 z-10 h-5 w-5 -translate-y-1/2 bg-white text-[#77736a]"
aria-hidden="true"
/>
) : null}
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-[11px] text-[#77736a]">
{String(index + 1).padStart(2, "0")}
</span>
<StatusIcon
className={cn("h-4 w-4", automationFlowStatusText(gate.status))}
aria-hidden="true"
/>
</div>
<p className="mt-3 min-h-[34px] text-xs font-semibold leading-4 text-[#141413]">
{gateLabel}
</p>
<div className="mt-3 h-1.5 bg-white/80">
<div
className={cn("h-full", automationFlowStatusFill(gate.status))}
style={{ width: `${passedPercent}%` }}
/>
</div>
<div className="mt-3 flex items-baseline justify-between gap-2">
<span className={cn("font-mono text-lg font-semibold", automationFlowStatusText(gate.status))}>
{passedPercent.toFixed(0)}%
</span>
<span className="text-[11px] font-semibold text-[#5f5b52]">
{t(automationFlowStatusLabelKey(gate.status) as never)}
</span>
</div>
<div className="mt-2 grid grid-cols-4 gap-px bg-[#e7e1d6] text-center font-mono text-[10px]">
<span className="bg-white px-1 py-1 text-[#17602a]">{gate.passed_total ?? 0}</span>
<span className="bg-white px-1 py-1 text-[#8a5a08]">{gate.warning_total ?? 0}</span>
<span className="bg-white px-1 py-1 text-[#9f2f25]">{gate.missing_total ?? 0}</span>
<span className="bg-white px-1 py-1 text-[#7d2018]">{gate.failed_total ?? 0}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-white p-4">
<div
className={cn(
"border p-4",
claimReady
? "border-[#c8dfcb] bg-[#f4fbf5] text-[#17602a]"
: "border-[#eed2ce] bg-[#fff6f5] text-[#8f2c22]"
)}
>
<div className="flex items-center gap-2 text-sm font-semibold">
{claimReady ? (
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
) : (
<AlertCircle className="h-4 w-4" aria-hidden="true" />
)}
<span>{claimReady ? t("claimReady") : t("claimBlocked")}</span>
</div>
<p className="mt-2 text-xs leading-5">{t("claimReason", { reason: claimReasonLabel })}</p>
</div>
<div className="mt-3 grid grid-cols-2 gap-px bg-[#e0ddd4]">
{[
{ label: t("metrics.evaluated"), value: flow.evaluated_total },
{ label: t("metrics.verifiedRepair"), value: summary.verified_auto_repair_total ?? 0 },
{ label: t("metrics.blockedGates"), value: blockedGates.length },
{ label: t("metrics.readiness"), value: `${readiness}%` },
].map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-[11px] font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">{item.value}</p>
</div>
))}
</div>
<div className="mt-3 border border-[#e0ddd4] bg-[#faf9f3] p-3">
<p className="text-xs font-semibold text-[#77736a]">{t("attentionTitle")}</p>
<p className="mt-2 text-sm font-semibold text-[#141413]">{attentionGateLabel}</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t("attentionAction", { action: attentionActionLabel })}
</p>
</div>
</div>
</div>
<div className="grid gap-px bg-[#e0ddd4] xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<div className="bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#77736a]">
{t("heatmapTitle")}
</p>
<div className="grid grid-cols-4 gap-px bg-[#e0ddd4] text-center text-[10px] font-semibold text-[#5f5b52]">
<span className="bg-white px-2 py-1">{t("heatmap.pass")}</span>
<span className="bg-white px-2 py-1">{t("heatmap.warn")}</span>
<span className="bg-white px-2 py-1">{t("heatmap.miss")}</span>
<span className="bg-white px-2 py-1">{t("heatmap.fail")}</span>
</div>
</div>
<div className="mt-3 space-y-2">
{gates.map((gate) => {
const gateLabelKey = AUTOMATION_FLOW_GATE_LABEL_KEYS[gate.gate];
const gateLabel = gateLabelKey
? t(`gates.${gateLabelKey}` as never)
: gate.gate;
const total = Math.max(1, gate.evaluated_total ?? 1);
const segments = [
{ key: "pass", value: gate.passed_total ?? 0, className: "bg-[#4f9d5f]" },
{ key: "warn", value: gate.warning_total ?? 0, className: "bg-[#c58a24]" },
{ key: "miss", value: gate.missing_total ?? 0, className: "bg-[#c65145]" },
{ key: "fail", value: gate.failed_total ?? 0, className: "bg-[#7d2018]" },
];
return (
<div key={gate.gate} className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)_110px] md:items-center">
<div className="flex items-center gap-2">
<span className={cn("h-2.5 w-2.5", automationFlowStatusFill(gate.status))} />
<span className="text-xs font-semibold text-[#141413]">{gateLabel}</span>
</div>
<div className="flex h-4 overflow-hidden bg-[#ece7dc]">
{segments.map((segment) => (
<div
key={segment.key}
className={segment.className}
style={{ width: `${(segment.value / total) * 100}%` }}
title={`${segment.key}: ${segment.value}`}
/>
))}
</div>
<div className="grid grid-cols-4 gap-px bg-[#e0ddd4] text-center font-mono text-[10px]">
<span className="bg-white py-1 text-[#17602a]">{gate.passed_total ?? 0}</span>
<span className="bg-white py-1 text-[#8a5a08]">{gate.warning_total ?? 0}</span>
<span className="bg-white py-1 text-[#9f2f25]">{gate.missing_total ?? 0}</span>
<span className="bg-white py-1 text-[#7d2018]">{gate.failed_total ?? 0}</span>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-white p-4">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#77736a]">
{t("bottleneckTitle")}
</p>
<div className="mt-3 space-y-2">
{(blockedGates.length ? blockedGates : warningGates).slice(0, 4).map((gate) => {
const gateLabelKey = AUTOMATION_FLOW_GATE_LABEL_KEYS[gate.gate];
const gateLabel = gateLabelKey
? t(`gates.${gateLabelKey}` as never)
: gate.gate;
const actionKey = gate.next_action
? AUTOMATION_FLOW_ACTION_KEYS[gate.next_action]
: null;
const actionLabel = actionKey
? t(`actions.${actionKey}` as never)
: gate.next_action ?? "--";
const example = gate.examples?.[0];
return (
<div
key={gate.gate}
className={cn("border p-3", automationFlowStatusSurface(gate.status))}
>
<div className="flex items-start justify-between gap-3">
<p className="text-xs font-semibold text-[#141413]">{gateLabel}</p>
<span
className={cn(
"border px-2 py-0.5 text-[11px] font-semibold",
automationFlowStatusClass(gate.status)
)}
>
{t(automationFlowStatusLabelKey(gate.status) as never)}
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t("nextAction", { action: actionLabel })}
</p>
{sourceStatuses ? (
<p className="break-words font-mono">
{t("sourceStatuses", { statuses: sourceStatuses })}
{example ? (
<p className="mt-2 truncate font-mono text-[11px] text-[#77736a]">
{example.incident_id ?? "--"} / {example.verdict ?? "--"}
</p>
) : null}
</div>
) : null}
</article>
);
})}
);
})}
</div>
</div>
</div>
</>
)}