Files
sundynix-agentix/sundynix-admin/src/pages/DashboardPage.tsx
T
Blizzard 55d50417a9 feat: 模型健康/熔断态 surface 到管理端(T4.F 可观测)
failover/熔断的运行时态原来只在 dispatcher 日志、admin 看不到 —— 本次接到概览可见:
- harness: CircuitBreaker.Snapshot() 只读观测访问器(state + fails,不动状态机)
- llm: Pool.ModelHealth() 上报主备链每模型 {provider,model,role,state,fails};
  buildWithFallbacks 把模型名↔breaker 配对(同包直接读 failoverModel.breakers);
  newFailoverModel 改返回具体类型以便读 breakers
- dispatcher 心跳 payload 加 models[]
- gateway /admin/overview 独立超时 Ping dispatcher,合并进 models.health
- admin 概览「模型路由」新增「运行时链路态(实时)」:逐模型状态点
  (🟢在线/🔴熔断中+失败数/🟡半开探测/单点)
- 单测:Snapshot、Pool.ModelHealth(名字↔态配对/单点/空)
- live:配坏主→提交任务打熔断→概览显示 broken-demo「熔断中·失败3」,备用在线

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:00:13 +08:00

381 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
// 数据来自 /api/v1/admin/overview(系统级聚合)+ /api/v1/admin/status(实时探活)。
const STATUS_STYLE: Record<string, { label: string; color: string }> = {
done: { label: "完成", color: "#10b981" },
running: { label: "运行中", color: "#06b6d4" },
failed: { label: "失败", color: "#f43f5e" },
timeout: { label: "超时", color: "#f59e0b" },
rejected: { label: "已拒绝", color: "#a78bfa" },
waiting: { label: "待审批", color: "#eab308" },
};
const statusStyle = (s: string) => STATUS_STYLE[s] ?? { label: s, color: "#94a3b8" };
// 模型熔断态 → 展示样式。
const BREAKER_STYLE: Record<string, { label: string; dot: string; text: string }> = {
closed: { label: "在线", dot: "bg-emerald-500", text: "text-emerald-600" },
open: { label: "熔断中", dot: "bg-rose-500", text: "text-rose-500" },
"half-open": { label: "半开探测", dot: "bg-amber-500", text: "text-amber-600" },
single: { label: "单点", dot: "bg-gray-300", text: "text-gray-400" },
};
const breakerStyle = (s: string) => BREAKER_STYLE[s] ?? { label: s, dot: "bg-gray-300", text: "text-gray-400" };
function last7DaysTrend(trend: { key: string; count: number }[]): { key: string; count: number }[] {
const byKey = new Map(trend.map((d) => [d.key, d.count]));
const out: { key: string; count: number }[] = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
const key = `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
out.push({ key, count: byKey.get(key) ?? 0 });
}
return out;
}
export function DashboardPage() {
const [ov, setOv] = useState<AdminOverview | null>(null);
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [err, setErr] = useState("");
const load = async () => {
setRefreshing(true);
try {
const [o, s] = await Promise.all([adminOverview(), getStatus()]);
setOv(o);
setStatus(s);
setUpdatedAt(new Date());
setErr("");
} catch (er) {
setErr((er as Error).message);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30000);
return () => clearInterval(t);
}, []);
const trend = useMemo(() => (ov ? last7DaysTrend(ov.task_trend) : []), [ov]);
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
if (!ov || !status) return null;
const maxTrend = Math.max(1, ...trend.map((d) => d.count));
const W = 560;
const H = 120;
const pad = 20;
const pts = trend.map((d, i) => ({
x: pad + (i * (W - pad * 2)) / Math.max(1, trend.length - 1),
y: H - pad - (d.count * (H - pad * 2)) / maxTrend,
}));
const dPath = pts.reduce((p, pt, i) => p + `${i === 0 ? "M" : "L"} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)} `, "");
const areaPath = pts.length ? dPath + `L ${pts[pts.length - 1].x} ${H - pad} L ${pts[0].x} ${H - pad} Z` : "";
const totalStatus = ov.status_count.reduce((s, d) => s + d.count, 0) || 1;
const upSvc = status.services.filter((s) => s.up).length;
const svcTotal = status.services.length;
const goTools = status.tools.find((t) => t.server === "mcp-go")?.tools?.length ?? 0;
const pyTools = status.tools.find((t) => t.server === "mcp-py")?.tools?.length ?? 0;
const infraDown = status.infra.filter((i) => !i.up).length;
const m = ov.models;
return (
<div className="space-y-6">
{/* 顶栏:更新时间 + 刷新 */}
<div className="flex items-center justify-end gap-3 text-xs text-gray-400">
{updatedAt && <span> {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })} · 30s · </span>}
<button
onClick={() => void load()}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{refreshing ? "刷新中" : "刷新"}
</button>
</div>
{/* A. 平台总览(全局口径) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="平台任务总量"
icon="tasks"
tone="violet"
value={ov.tasks_total.toLocaleString()}
sub={<span className="text-emerald-600"> +{ov.tasks_today}</span>}
/>
<MetricCard
label="全局评测质量"
icon="award"
tone="emerald"
value={
<>
{ov.eval_avg.toFixed(2)} <span className="text-xs font-normal text-gray-400">/ 1.0</span>
</>
}
sub={<span className="text-gray-400">{ov.eval_count} · </span>}
/>
<MetricCard
label="平台规模"
icon="users"
tone="cyan"
value={ov.users.toLocaleString()}
sub={<span className="text-gray-400"> · {ov.kb_count} / {ov.kb_docs} </span>}
/>
<MetricCard
label="服务在线"
icon="pulse"
tone={upSvc === svcTotal && infraDown === 0 ? "emerald" : "rose"}
value={`${upSvc} / ${svcTotal}`}
sub={
infraDown === 0 && upSvc === svcTotal ? (
<span className="text-emerald-600"> + 绿 · {goTools + pyTools} </span>
) : (
<span className="text-rose-600">{infraDown > 0 ? `${infraDown} 项基建异常` : "有服务离线"}</span>
)
}
/>
</div>
{/* B. 控制面配置态(管理端独有) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"> & Fallback</h4>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<div className="space-y-3">
<Row label="主对话模型">
{m.active_chat ? (
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
<code className="text-[11px] text-gray-700">{m.active_chat}</code>
</span>
) : (
<span className="text-rose-500"></span>
)}
</Row>
<Row label="备用链(failover">
{m.fallbacks === 0 ? <span className="text-amber-600"></span> : <span className="text-gray-500">{m.fallbacks} </span>}
</Row>
<Row label="向量模型">
{m.active_embedding ? <code className="text-[11px] text-gray-700">{m.active_embedding}</code> : <span className="text-rose-500"></span>}
</Row>
<Row label="已登记模型">
<span className="text-gray-500">chat {m.chat_count} · embedding {m.embedding_count}</span>
</Row>
</div>
{/* 运行时 failover/熔断态(来自 dispatcher */}
{m.health && m.health.length > 0 && (
<div className="mt-3 border-t border-gray-50 pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300"></div>
<div className="space-y-1.5">
{m.health.map((h, i) => {
const st = breakerStyle(h.state);
return (
<div key={`${h.model}-${i}`} className="flex items-center gap-2 text-xs">
<span className={`h-2 w-2 shrink-0 rounded-full ${st.dot}`} title={st.label} />
<span className="rounded bg-gray-50 px-1.5 text-[10px] text-gray-400">{h.role === "primary" ? "主" : "备"}</span>
<code className="min-w-0 flex-1 truncate text-[11px] text-gray-600">{h.model}</code>
<span className={`shrink-0 text-[10px] ${st.text}`}>{st.label}</span>
{h.fails > 0 && h.state !== "closed" && <span className="shrink-0 text-[10px] text-gray-300"> {h.fails}</span>}
</div>
);
})}
</div>
</div>
)}
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<div className="flex items-baseline gap-6">
<div>
<div className="text-2xl font-bold text-gray-800">{ov.prompts.managed}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<div>
<div className="text-2xl font-bold text-violet-600">{ov.prompts.overrides}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">{Math.max(0, ov.prompts.managed - ov.prompts.overrides)}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
<p className="mt-3 text-[11px] text-gray-400"> NATS </p>
</div>
</div>
{/* C. 全局任务吞吐 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 /</p>
</div>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
{areaPath && <path d={areaPath} fill="url(#g)" />}
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
</g>
))}
</svg>
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
{trend.map((d) => (
<span key={d.key}>{d.key}</span>
))}
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> 7 </p>
</div>
<div className="space-y-3">
{ov.status_count.length === 0 && <div className="text-xs text-gray-400"></div>}
{ov.status_count
.slice()
.sort((a, b) => b.count - a.count)
.map((d) => {
const st = statusStyle(d.key);
const pct = (d.count / totalStatus) * 100;
return (
<div key={d.key}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
{st.label}
</span>
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
</div>
</div>
);
})}
</div>
</div>
</div>
{/* D. 系统健康拓扑 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"></h4>
<p className="text-[11px] text-gray-400"> + + MCP NATS </p>
</div>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700"></span>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
<HealthGroup
title="MCP 工具组"
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
/>
</div>
</div>
</div>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between border-b border-gray-50 pb-2 text-xs last:border-0 last:pb-0">
<span className="text-gray-400">{label}</span>
<span className="text-right">{children}</span>
</div>
);
}
function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
return (
<div>
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
{items.map((it) => (
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
<span className="flex items-center gap-1.5 text-gray-600">
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
{it.name}
</span>
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
</div>
))}
</div>
);
}
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
const TONE: Record<Tone, string> = {
violet: "bg-violet-50 text-violet-600",
emerald: "bg-emerald-50 text-emerald-600",
cyan: "bg-cyan-50 text-cyan-600",
amber: "bg-amber-50 text-amber-600",
rose: "bg-rose-50 text-rose-600",
};
function MetricCard({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub: ReactNode; icon: IconName; tone: Tone }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-400">{label}</span>
<div className={`rounded-lg p-2 ${TONE[tone]}`}>
<Icon name={icon} className="h-5 w-5" />
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-800">{value}</h3>
<p className="mt-1 text-xs">{sub}</p>
</div>
</div>
);
}
type IconName = "tasks" | "award" | "pulse" | "users";
const PATHS: Record<IconName, string> = {
tasks: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
award: "M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M8.21 13.89 7 23l5-3 5 3-1.21-9.12",
pulse: "M22 12h-4l-3 9L9 3l-3 9H2",
users: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M22 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
};
function Icon({ name, className }: { name: IconName; className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={PATHS[name]} />
</svg>
);
}