feat(awooop): surface status chain in operator console
All checks were successful
Code Review / ai-code-review (push) Successful in 9s
CD Pipeline / tests (push) Successful in 1m16s
CD Pipeline / build-and-deploy (push) Successful in 3m31s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s

This commit is contained in:
Your Name
2026-05-19 10:13:33 +08:00
parent 30b2f5bd6e
commit 784ebf49ef
8 changed files with 615 additions and 4 deletions

View File

@@ -0,0 +1,195 @@
"use client";
import { Activity, CheckCircle2, Route, ShieldAlert, TriangleAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
export interface AwoooPStatusChain {
schema_version?: string;
source?: string;
source_id?: string | null;
incident_ids?: string[];
current_stage?: string | null;
stage_status?: string | null;
verdict?: string | null;
repair_state?: string | null;
verification?: string | null;
needs_human?: boolean | null;
next_step?: string | null;
blockers?: string[];
fetch_error?: string | null;
evidence?: {
auto_repair_records?: number | null;
operation_records?: number | null;
mcp_gateway_total?: number | null;
knowledge_entries?: number | null;
remediation_total?: number | null;
remediation_state?: string | null;
latest_route?: string | null;
latest_mode?: string | null;
latest_at?: string | null;
latest_preview?: string | null;
};
writes?: {
incident?: boolean | null;
auto_repair?: boolean | null;
};
}
function toneClass(chain?: AwoooPStatusChain | null) {
if (!chain) return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
if (chain.needs_human) return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
if (chain.repair_state === "auto_repaired_verified") {
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
}
if (chain.repair_state === "executed" || chain.stage_status === "success") {
return "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
}
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
function chainIcon(chain?: AwoooPStatusChain | null) {
if (!chain) return Route;
if (chain.needs_human) return TriangleAlert;
if (chain.repair_state === "auto_repaired_verified") return CheckCircle2;
if (chain.repair_state === "blocked_manual_required") return ShieldAlert;
return Activity;
}
function boolValue(value: boolean | null | undefined, emptyLabel: string) {
if (value === true) return "true";
if (value === false) return "false";
return emptyLabel;
}
function valueOrEmpty(value: unknown, emptyLabel: string) {
if (value === null || value === undefined || value === "") return emptyLabel;
return String(value);
}
export function AwoooPStatusChainPanel({
chain,
compact = false,
className,
}: {
chain?: AwoooPStatusChain | null;
compact?: boolean;
className?: string;
}) {
const t = useTranslations("awooop.statusChain");
const Icon = chainIcon(chain);
const tone = toneClass(chain);
const emptyLabel = t("emptyValue");
const evidence = chain?.evidence ?? {};
const blockers = chain?.blockers ?? [];
if (!chain) {
return (
<section className={cn("border border-[#e0ddd4] bg-white", compact ? "p-3" : "", className)}>
<div className={cn("flex items-start gap-3", !compact && "px-4 py-3")}>
<span className="flex h-8 w-8 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]">
<Route className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">{t("empty")}</p>
</div>
</div>
</section>
);
}
const metrics = [
{ label: t("fields.stage"), value: `${valueOrEmpty(chain.current_stage, emptyLabel)} / ${valueOrEmpty(chain.stage_status, emptyLabel)}` },
{ label: t("fields.repair"), value: valueOrEmpty(chain.repair_state, emptyLabel) },
{ label: t("fields.verification"), value: valueOrEmpty(chain.verification, emptyLabel) },
{ label: t("fields.nextStep"), value: valueOrEmpty(chain.next_step, emptyLabel) },
];
const evidenceMetrics = [
{ label: t("evidence.autoRepair"), value: evidence.auto_repair_records ?? 0 },
{ label: t("evidence.ops"), value: evidence.operation_records ?? 0 },
{ label: t("evidence.mcp"), value: evidence.mcp_gateway_total ?? 0 },
{ label: t("evidence.km"), value: evidence.knowledge_entries ?? 0 },
];
return (
<section className={cn("border border-[#e0ddd4] bg-white", className)}>
<div className={cn(
"flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3]",
compact ? "px-3 py-3" : "px-4 py-3"
)}>
<div className="flex min-w-0 items-start gap-3">
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center border", tone)}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">
{t("subtitle", {
source: valueOrEmpty(chain.source, emptyLabel),
sourceId: valueOrEmpty(chain.source_id, emptyLabel),
})}
</p>
</div>
</div>
<span className={cn("shrink-0 border px-2 py-0.5 text-xs font-semibold", tone)}>
{chain.needs_human ? t("human.yes") : t("human.no")}
</span>
</div>
<div className={cn("grid gap-px bg-[#e0ddd4]", compact ? "md:grid-cols-2" : "md:grid-cols-4")}>
{metrics.map((item) => (
<div key={item.label} className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={item.value}>
{item.value}
</p>
</div>
))}
</div>
{!compact && (
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-5">
{evidenceMetrics.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-xl font-semibold text-[#141413]">{item.value}</p>
</div>
))}
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("evidence.adr100")}</p>
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={valueOrEmpty(evidence.latest_route, emptyLabel)}>
{valueOrEmpty(evidence.latest_route, emptyLabel)}
</p>
</div>
</div>
)}
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2">
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("fields.writes")}</p>
<p className="mt-2 font-mono text-sm text-[#141413]">
{t("writeFlags", {
incident: boolValue(chain.writes?.incident, emptyLabel),
autoRepair: boolValue(chain.writes?.auto_repair, emptyLabel),
})}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("fields.verdict")}</p>
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={valueOrEmpty(chain.verdict, emptyLabel)}>
{valueOrEmpty(chain.verdict, emptyLabel)}
</p>
</div>
</div>
{blockers.length > 0 && (
<div className="border-t border-[#eee9dd] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
<span className="font-semibold">{t("blockers")}</span>{" "}
<span className="font-mono">{blockers.slice(0, compact ? 2 : 4).join(", ")}</span>
</div>
)}
</section>
);
}