fix(web): 在 Runs 顯示自動化資產沉澱
All checks were successful
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Successful in 1m41s
CD Pipeline / build-and-deploy (push) Successful in 7m53s
CD Pipeline / post-deploy-checks (push) Successful in 2m21s

This commit is contained in:
Your Name
2026-06-18 16:24:29 +08:00
parent 3e1da74cd6
commit 11c2b5d490
5 changed files with 171 additions and 92 deletions

View File

@@ -9464,6 +9464,24 @@
}
}
},
"automationAssetLedger": {
"column": "資產沉澱",
"title": "資產沉澱",
"summary": "完成 {ready} / 卡點 {blocked}",
"boundary": "只讀推導;不代表已寫入 KM、更新 PlayBook trust、套用腳本 / 排程或執行 verifier。",
"items": {
"km": "KM",
"playbook": "PlayBook",
"script": "腳本",
"schedule": "排程",
"verifier": "Verifier"
},
"values": {
"ready": "ready",
"pending": "pending",
"blocked": "blocked"
}
},
"runs": {
"automationFlow": {
"title": "AI 自動化流程 Gate",

View File

@@ -9464,6 +9464,24 @@
}
}
},
"automationAssetLedger": {
"column": "資產沉澱",
"title": "資產沉澱",
"summary": "完成 {ready} / 卡點 {blocked}",
"boundary": "只讀推導;不代表已寫入 KM、更新 PlayBook trust、套用腳本 / 排程或執行 verifier。",
"items": {
"km": "KM",
"playbook": "PlayBook",
"script": "腳本",
"schedule": "排程",
"verifier": "Verifier"
},
"values": {
"ready": "ready",
"pending": "pending",
"blocked": "blocked"
}
},
"runs": {
"automationFlow": {
"title": "AI 自動化流程 Gate",

View File

@@ -29,6 +29,7 @@ import {
AwoooPStatusChainPanel,
type AwoooPStatusChain,
} from "@/components/awooop/status-chain";
import { AwoooPAutomationAssetLedger } from "@/components/awooop/automation-asset-ledger";
import type { IncidentTimelineResponse } from "@/lib/api-client";
import {
publicAgentText,
@@ -291,95 +292,6 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
);
}
type ApprovalAssetTone = "ready" | "pending" | "blocked";
function assetToneClass(tone: ApprovalAssetTone) {
if (tone === "ready") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
function countLabel(value: number) {
return value > 0 ? String(value) : "--";
}
function ApprovalAutomationAssetLedger({ approval }: { approval: Approval }) {
const t = useTranslations("awooop.approvals.assetLedger");
const chain = approval.awooop_status_chain;
const summary = approval.remediation_summary;
const kmCount = chain?.evidence?.knowledge_entries ?? 0;
const playbookCount = (chain?.execution?.playbook_ids?.length ?? 0) + (chain?.execution?.playbook_paths?.length ?? 0);
const ansible = chain?.execution?.ansible;
const scriptCount = (ansible?.candidate_count ?? 0) + (ansible?.check_mode_total ?? 0) + (ansible?.apply_total ?? 0);
const correlation = chain?.source_refs?.correlation;
const scheduleCount = (correlation?.provider_event_total ?? 0)
+ (correlation?.direct_ref_total ?? 0)
+ (correlation?.candidate_total ?? 0)
+ (correlation?.applied_link_total ?? 0);
const verification = String(chain?.verification ?? "").toLowerCase();
const verifierReady = verification.includes("verified") || verification.includes("success");
const verifierBlocked = verification.includes("degraded") || verification.includes("fail");
const route = String(summary?.latest_route ?? "");
const noRepairCandidate = route.includes("fallback") || route.includes("no_action") || chain?.blockers?.some((item) => item.includes("playbook") || item.includes("candidate"));
const items = [
{
key: "km",
tone: kmCount > 0 ? "ready" : chain?.needs_human ? "blocked" : "pending",
value: countLabel(kmCount),
},
{
key: "playbook",
tone: playbookCount > 0 ? "ready" : noRepairCandidate ? "blocked" : "pending",
value: countLabel(playbookCount),
},
{
key: "script",
tone: scriptCount > 0 ? "ready" : summary?.has_mcp_investigation ? "pending" : "blocked",
value: countLabel(scriptCount),
},
{
key: "schedule",
tone: scheduleCount > 0 ? "ready" : "pending",
value: countLabel(scheduleCount),
},
{
key: "verifier",
tone: verifierReady ? "ready" : verifierBlocked ? "blocked" : "pending",
value: verifierReady ? t("values.ready") : verifierBlocked ? t("values.blocked") : t("values.pending"),
},
] satisfies Array<{ key: string; tone: ApprovalAssetTone; value: string }>;
const readyCount = items.filter((item) => item.tone === "ready").length;
const blockedCount = items.filter((item) => item.tone === "blocked").length;
return (
<div className="min-w-[260px] border border-[#e0ddd4] bg-white p-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#141413]">{t("title")}</span>
<span className={cn(
"border px-2 py-0.5 font-mono text-[11px] font-semibold",
blockedCount > 0 ? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]" : "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
)}>
{t("summary", { ready: readyCount, blocked: blockedCount })}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map((item) => (
<div key={item.key} className={cn("min-w-0 border px-2 py-1", assetToneClass(item.tone))}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[11px] font-semibold">{t(`items.${item.key}` as never)}</span>
<span className="font-mono text-[11px] font-semibold">{item.value}</span>
</div>
</div>
))}
</div>
<p className="mt-2 text-[11px] leading-5 text-[#77736a]">
{t("boundary")}
</p>
</div>
);
}
function LegacyHitlBacklogPanel({
approvals,
loading,
@@ -681,7 +593,10 @@ function ApprovalRow({ approval }: { approval: Approval }) {
<ApprovalSourceFlowCell chain={approval.awooop_status_chain} />
</td>
<td className="px-4 py-3">
<ApprovalAutomationAssetLedger approval={approval} />
<AwoooPAutomationAssetLedger
chain={approval.awooop_status_chain}
remediationSummary={approval.remediation_summary}
/>
</td>
<td className="min-w-[280px] px-4 py-3">
<AwoooPStatusChainPanel chain={approval.awooop_status_chain} compact />

View File

@@ -12,6 +12,7 @@ import {
AwoooPStatusChainPanel,
type AwoooPStatusChain,
} from "@/components/awooop/status-chain";
import { AwoooPAutomationAssetLedger } from "@/components/awooop/automation-asset-ledger";
import { useIncidentStatusChains } from "@/hooks/useIncidentStatusChains";
import {
Activity,
@@ -1939,6 +1940,12 @@ function RunRow({
loading={sourceFlowLoading}
/>
</td>
<td className="px-4 py-3">
<AwoooPAutomationAssetLedger
chain={statusChain}
remediationSummary={run.remediation_summary}
/>
</td>
<td className="px-4 py-3">
<ShadowBadge isShadow={run.is_shadow} />
</td>
@@ -3960,6 +3967,7 @@ function AiRouteStatusPanel({
export default function RunsPage() {
const tEvidence = useTranslations("awooop.listEvidence");
const tAssetLedger = useTranslations("awooop.automationAssetLedger");
const tCallback = useTranslations("awooop.callbackReply");
const [runs, setRuns] = useState<Run[]>([]);
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
@@ -4564,6 +4572,9 @@ export default function RunsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{tEvidence("sourceFlow.column")}
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{tAssetLedger("column")}
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Shadow
</th>
@@ -4579,7 +4590,7 @@ export default function RunsPage() {
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 12 }).map((_, j) => (
{Array.from({ length: 13 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>
@@ -4588,7 +4599,7 @@ export default function RunsPage() {
))
) : runs.length === 0 && !error ? (
<tr>
<td colSpan={12} className="px-4 py-16 text-center">
<td colSpan={13} className="px-4 py-16 text-center">
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground"> Run </p>
</td>

View File

@@ -0,0 +1,117 @@
"use client";
import { useTranslations } from "next-intl";
import type { AwoooPStatusChain } from "@/components/awooop/status-chain";
import { cn } from "@/lib/utils";
type AutomationAssetTone = "ready" | "pending" | "blocked";
export interface AutomationAssetRemediationSummary {
has_mcp_investigation?: boolean | null;
latest_route?: string | null;
}
function assetToneClass(tone: AutomationAssetTone) {
if (tone === "ready") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
function countLabel(value: number) {
return value > 0 ? String(value) : "--";
}
export function AwoooPAutomationAssetLedger({
chain,
remediationSummary,
className,
}: {
chain?: AwoooPStatusChain | null;
remediationSummary?: AutomationAssetRemediationSummary | null;
className?: string;
}) {
const t = useTranslations("awooop.automationAssetLedger");
const kmCount = chain?.evidence?.knowledge_entries ?? 0;
const playbookCount = (chain?.execution?.playbook_ids?.length ?? 0)
+ (chain?.execution?.playbook_paths?.length ?? 0);
const ansible = chain?.execution?.ansible;
const scriptCount = (ansible?.candidate_count ?? 0)
+ (ansible?.check_mode_total ?? 0)
+ (ansible?.apply_total ?? 0);
const correlation = chain?.source_refs?.correlation;
const scheduleCount = (correlation?.provider_event_total ?? 0)
+ (correlation?.direct_ref_total ?? 0)
+ (correlation?.candidate_total ?? 0)
+ (correlation?.applied_link_total ?? 0);
const verification = String(chain?.verification ?? "").toLowerCase();
const verifierReady = verification.includes("verified") || verification.includes("success");
const verifierBlocked = verification.includes("degraded") || verification.includes("fail");
const route = String(remediationSummary?.latest_route ?? chain?.evidence?.latest_route ?? "");
const noRepairCandidate = route.includes("fallback")
|| route.includes("no_action")
|| chain?.blockers?.some((item) => item.includes("playbook") || item.includes("candidate"));
const items = [
{
key: "km",
tone: kmCount > 0 ? "ready" : chain?.needs_human ? "blocked" : "pending",
value: countLabel(kmCount),
},
{
key: "playbook",
tone: playbookCount > 0 ? "ready" : noRepairCandidate ? "blocked" : "pending",
value: countLabel(playbookCount),
},
{
key: "script",
tone: scriptCount > 0
? "ready"
: remediationSummary?.has_mcp_investigation || (chain?.mcp?.gateway?.total ?? 0) > 0
? "pending"
: "blocked",
value: countLabel(scriptCount),
},
{
key: "schedule",
tone: scheduleCount > 0 ? "ready" : "pending",
value: countLabel(scheduleCount),
},
{
key: "verifier",
tone: verifierReady ? "ready" : verifierBlocked ? "blocked" : "pending",
value: verifierReady ? t("values.ready") : verifierBlocked ? t("values.blocked") : t("values.pending"),
},
] satisfies Array<{ key: string; tone: AutomationAssetTone; value: string }>;
const readyCount = items.filter((item) => item.tone === "ready").length;
const blockedCount = items.filter((item) => item.tone === "blocked").length;
return (
<div className={cn("min-w-[260px] border border-[#e0ddd4] bg-white p-2", className)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#141413]">{t("title")}</span>
<span className={cn(
"border px-2 py-0.5 font-mono text-[11px] font-semibold",
blockedCount > 0
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
)}>
{t("summary", { ready: readyCount, blocked: blockedCount })}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map((item) => (
<div key={item.key} className={cn("min-w-0 border px-2 py-1", assetToneClass(item.tone))}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[11px] font-semibold">{t(`items.${item.key}` as never)}</span>
<span className="font-mono text-[11px] font-semibold">{item.value}</span>
</div>
</div>
))}
</div>
<p className="mt-2 text-[11px] leading-5 text-[#77736a]">
{t("boundary")}
</p>
</div>
);
}