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>
This commit is contained in:
Blizzard
2026-07-06 12:00:13 +08:00
parent 6a258fc884
commit 55d50417a9
11 changed files with 1719 additions and 17 deletions
+17 -1
View File
@@ -219,6 +219,15 @@ export async function statsOverview(): Promise<Overview> {
// —— 管理端系统级聚合(控制塔口径,RequireAdmin)——
// 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。
// 单模型运行时健康态(failover 链上主/备各一条)。
export interface ModelHealthItem {
provider: string;
model: string;
role: string; // primary / fallback
state: string; // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链)
fails: number;
}
export interface AdminOverview {
users: number;
kb_count: number; // 全平台知识库数
@@ -230,7 +239,14 @@ export interface AdminOverview {
eval_avg: number;
faithful_avg: number;
eval_count: number;
models: { chat_count: number; embedding_count: number; active_chat: string; active_embedding: string; fallbacks: number };
models: {
chat_count: number;
embedding_count: number;
active_chat: string;
active_embedding: string;
fallbacks: number;
health: ModelHealthItem[]; // 运行时每模型 failover/熔断态(来自 dispatcher
};
prompts: { managed: number; overrides: number };
services: Record<string, boolean>;
checked_at: string;
@@ -14,6 +14,15 @@ const STATUS_STYLE: Record<string, { label: string; color: string }> = {
};
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 }[] = [];
@@ -169,6 +178,27 @@ export function DashboardPage() {
<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">