55d50417a9
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>
381 lines
18 KiB
TypeScript
381 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|