feat(admin): 服务状态面板(基建/服务探活 + MCP 工具注册)+ mcp-go 工具注册表

管理端新增「运维 › 服务状态」:总览横幅 + 摘要数字 + 请求链路拓扑
(客户端→网关→NATS→调度→MCP,按健康三态着色)+ 应用服务卡(含探针
延迟)+ 基建磁贴 + MCP 工具按能力域分组(中文名/作用)。

探活机制(全走 NATS,无 HTTP):
- mcp-go/mcp-py 新增 list_tools 自省工具,能应答=在线 + 上报工具清单
- dispatcher 无端点 → 新增 NATS 心跳主题 sundynix.health.dispatcher
  (ServeHealth 应答 model/ready/uptime),网关用 bus.Ping 探
- 网关 GET /api/v1/admin/status 并发聚合四探针 + 各项延迟

mcp-go 重构:switch → map 注册表(buildRegistry),dispatch 与 list_tools
共用单一事实源,杜绝漂移;每个工具带中文名 + 作用描述。mcp-py 同样补元信息。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-22 16:38:10 +08:00
parent 0edfc948ba
commit 8bcb90cdb2
13 changed files with 750 additions and 37 deletions
+30
View File
@@ -151,3 +151,33 @@ export async function gatewayOnline(): Promise<boolean> {
return false;
}
}
// —— 服务状态:基建 / 应用服务探活 + MCP 工具注册 ——
export interface StatusItem {
name: string;
up: boolean;
detail?: string;
latency_ms?: number;
}
export interface ToolInfo {
name: string;
cn: string;
desc: string;
}
export interface ToolGroup {
server: string;
up: boolean;
tools: ToolInfo[] | null;
}
export interface SystemStatus {
checked_at: string;
infra: StatusItem[];
services: StatusItem[];
tools: ToolGroup[];
}
export async function getStatus(): Promise<SystemStatus> {
const res = guard(await fetch(`${ADMIN}/status`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`status failed: ${res.status}`);
return (await res.json()) as SystemStatus;
}
+368
View File
@@ -0,0 +1,368 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
const REFRESH_SEC = 5;
// 服务/基建的展示元数据(角色说明 + 图标)。
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
};
const INFRA_META: Record<string, { role: string; icon: IconName }> = {
postgres: { role: "关系库 · 5432", icon: "db" },
redis: { role: "缓存 / 限流 · 6379", icon: "db" },
nats: { role: "消息总线 · 4222", icon: "bus" },
milvus: { role: "向量库 · 19530", icon: "db" },
neo4j: { role: "图数据库 · 7687", icon: "bus" },
};
// 工具按名称前缀归类,便于一眼看清能力域。
function toolCategory(t: string): string {
if (t.startsWith("memory_")) return "记忆";
if (t.startsWith("kb_") || t.startsWith("wiki_")) return "知识库 / 检索";
if (t.startsWith("report_")) return "报告";
if (t.startsWith("history_")) return "会话历史";
if (t.startsWith("external_")) return "外部接入";
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
return "系统";
}
export function StatusPage() {
const [data, setData] = useState<SystemStatus | null>(null);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [auto, setAuto] = useState(true);
const [countdown, setCountdown] = useState(REFRESH_SEC);
const autoRef = useRef(auto);
autoRef.current = auto;
const load = useCallback(async () => {
setBusy(true);
try {
setData(await getStatus());
setErr("");
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
setBusy(false);
setCountdown(REFRESH_SEC);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// 1s 心跳:倒计时显示 + 到点自动刷新(可暂停)。
useEffect(() => {
const id = window.setInterval(() => {
if (!autoRef.current) return;
setCountdown((c) => {
if (c <= 1) {
void load();
return REFRESH_SEC;
}
return c - 1;
});
}, 1000);
return () => window.clearInterval(id);
}, [load]);
const svc = (n: string) => data?.services.find((s) => s.name === n);
const infra = (n: string) => data?.infra.find((s) => s.name === n);
const servicesUp = data?.services.filter((s) => s.up).length ?? 0;
const infraUp = data?.infra.filter((s) => s.up).length ?? 0;
const toolCount = data?.tools.reduce((n, g) => n + (g.up ? g.tools?.length ?? 0 : 0), 0) ?? 0;
const downCount =
(data?.services.filter((s) => !s.up).length ?? 0) + (data?.infra.filter((s) => !s.up).length ?? 0);
const allUp = data != null && downCount === 0;
if (loading && !data) return <div className="text-sm text-gray-400"></div>;
if (!data) return <div className="text-sm text-rose-600">{err}</div>;
return (
<div className="space-y-5">
{/* 总览横幅 */}
<div
className={`flex flex-wrap items-center gap-4 rounded-xl border p-4 ${
allUp ? "border-emerald-200 bg-emerald-50/60" : "border-amber-200 bg-amber-50/60"
}`}
>
<div
className={`flex h-11 w-11 items-center justify-center rounded-full ${
allUp ? "bg-emerald-500" : "bg-amber-500"
} text-white`}
>
<Icon name={allUp ? "check" : "alert"} className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-gray-800">
{allUp ? "系统运行正常" : `${downCount} 项异常`}
</div>
<div className="text-xs text-gray-500">
{allUp ? "所有服务与基建均已就绪" : "部分服务或基建未就绪,请检查下方明细"}
</div>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-gray-500">
<button
onClick={() => setAuto((a) => !a)}
className={`rounded-full px-2.5 py-1 ${
auto ? "bg-emerald-100 text-emerald-700" : "bg-gray-200 text-gray-500"
}`}
title="自动刷新"
>
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
</button>
<button
onClick={() => void load()}
className="flex items-center gap-1.5 rounded-full border bg-white px-3 py-1 text-violet-600 hover:bg-violet-50"
>
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
</button>
</div>
</div>
{/* 摘要数字 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="应用服务" value={`${servicesUp}/${data.services.length}`} ok={servicesUp === data.services.length} />
<Stat label="基建环境" value={`${infraUp}/${data.infra.length}`} ok={infraUp === data.infra.length} />
<Stat label="已注册工具" value={toolCount} ok />
<Stat label="最后检查" value={new Date(data.checked_at).toLocaleTimeString()} ok muted />
</div>
{/* 请求链路拓扑 */}
<Panel title="请求链路" hint="桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层">
<div className="flex flex-wrap items-stretch gap-1 overflow-x-auto py-1">
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" state="up" />
<Arrow ok={svc("gateway")?.up} />
<FlowNode icon="gateway" label="网关" sub="Gateway :8080" state={state(svc("gateway")?.up)} />
<Arrow ok={infra("nats")?.up} />
<FlowNode icon="bus" label="NATS 总线" sub="JetStream" state={state(infra("nats")?.up)} />
<Arrow ok={svc("dispatcher")?.up} />
<FlowNode icon="cpu" label="调度中心" sub="Dispatcher" state={state(svc("dispatcher")?.up)} />
<Arrow ok={svc("mcp-go")?.up || svc("mcp-py")?.up} />
<FlowNode
icon="tool"
label="MCP 工具层"
sub="mcp-go · mcp-py"
state={mcpState(svc("mcp-go")?.up, svc("mcp-py")?.up)}
/>
</div>
</Panel>
{/* 应用服务 */}
<Panel title="应用服务" hint="四个进程的存活与探针耗时">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{data.services.map((s) => (
<ServiceCard key={s.name} item={s} />
))}
</div>
</Panel>
{/* 基建环境 */}
<Panel title="基建环境" hint="Postgres / Redis / NATS / Milvus / Neo4j">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
{data.infra.map((s) => (
<InfraTile key={s.name} item={s} />
))}
</div>
</Panel>
{/* MCP 工具注册 */}
<Panel title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组">
<div className="space-y-3">
{data.tools.map((g) => (
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
))}
</div>
</Panel>
</div>
);
}
// ---- 子组件 ----
type FlowState = "up" | "down" | "partial";
const state = (up?: boolean): FlowState => (up ? "up" : "down");
const mcpState = (a?: boolean, b?: boolean): FlowState => (a && b ? "up" : a || b ? "partial" : "down");
function Stat({ label, value, ok, muted }: { label: string; value: string | number; ok: boolean; muted?: boolean }) {
return (
<div className="rounded-xl border bg-white p-3">
<div className="text-[11px] text-gray-400">{label}</div>
<div className={`mt-1 text-xl font-semibold ${muted ? "text-gray-700" : ok ? "text-gray-800" : "text-rose-600"}`}>
{value}
</div>
</div>
);
}
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
return (
<section className="rounded-xl border bg-white p-4">
<div className="mb-3 flex items-baseline gap-2">
<h3 className="text-sm font-semibold text-gray-700">{title}</h3>
<span className="text-[11px] text-gray-400">{hint}</span>
</div>
{children}
</section>
);
}
const FLOW_TONE: Record<FlowState, string> = {
up: "border-emerald-200 bg-emerald-50 text-emerald-700",
partial: "border-amber-200 bg-amber-50 text-amber-700",
down: "border-rose-200 bg-rose-50 text-rose-700",
};
function FlowNode({ icon, label, sub, state }: { icon: IconName; label: string; sub: string; state: FlowState }) {
return (
<div className={`flex min-w-[120px] flex-1 flex-col items-center rounded-lg border px-3 py-2.5 text-center ${FLOW_TONE[state]}`}>
<Icon name={icon} className="mb-1 h-5 w-5" />
<div className="text-xs font-semibold">{label}</div>
<div className="text-[10px] opacity-70">{sub}</div>
</div>
);
}
function Arrow({ ok }: { ok?: boolean }) {
return (
<div className="flex shrink-0 items-center px-0.5">
<svg viewBox="0 0 24 24" className={`h-4 w-8 ${ok ? "text-emerald-400" : "text-rose-300"}`}>
<path d="M2 12 h17 M15 7 l5 5 -5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
function ServiceCard({ item }: { item: StatusItem }) {
const meta = SERVICE_META[item.name];
return (
<div className={`rounded-lg border p-3.5 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
item.up ? "bg-violet-50 text-violet-600" : "bg-rose-100 text-rose-500"
}`}
>
<Icon name={meta?.icon ?? "tool"} className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">{item.name}</span>
<StatusPill up={item.up} />
</div>
<div className="truncate text-[11px] text-gray-400">{meta?.role}</div>
</div>
{item.up && item.latency_ms != null && (
<span className="ml-auto flex items-center gap-1 text-[11px] text-gray-400" title="探针往返耗时">
<Icon name="bolt" className="h-3 w-3" />
{item.latency_ms}ms
</span>
)}
</div>
<div className={`mt-2 border-t pt-2 text-xs ${item.up ? "text-gray-500" : "text-rose-600"}`}>{item.detail}</div>
</div>
);
}
function InfraTile({ item }: { item: StatusItem }) {
const meta = INFRA_META[item.name];
return (
<div className={`rounded-lg border p-3 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
<div className="flex items-center gap-2">
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 ${item.up ? "text-gray-400" : "text-rose-400"}`} />
<span className="text-sm font-medium text-gray-800">{item.name}</span>
<span className={`ml-auto h-2 w-2 rounded-full ${item.up ? "bg-emerald-500" : "bg-rose-500"}`} />
</div>
<div className="mt-1 text-[11px] text-gray-400">{meta?.role}</div>
<div className={`mt-0.5 text-[11px] font-medium ${item.up ? "text-emerald-600" : "text-rose-600"}`}>
{item.up ? "就绪" : "离线"}
</div>
</div>
);
}
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
// 按能力域分组。
const groups: Record<string, ToolInfo[]> = {};
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
const order = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
const cats = Object.keys(groups).sort((a, b) => order.indexOf(a) - order.indexOf(b));
return (
<div className={`rounded-lg border p-3 ${up ? "" : "border-rose-200 bg-rose-50/40"}`}>
<div className="mb-3 flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${up ? "bg-emerald-500" : "bg-rose-500"}`} />
<span className="text-sm font-medium text-gray-800">{server}</span>
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
</div>
{up && cats.length > 0 && (
<div className="space-y-3">
{cats.map((c) => (
<div key={c}>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{c}</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3">
{groups[c].map((t) => (
<div key={t.name} className="rounded-lg border bg-gray-50/60 px-2.5 py-1.5">
<div className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-gray-700">{t.cn}</span>
<span className="font-mono text-[10px] text-gray-400">{t.name}</span>
</div>
<div className="text-[10px] leading-snug text-gray-500">{t.desc}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
function StatusPill({ up }: { up: boolean }) {
return up ? (
<span className="flex items-center gap-1 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
</span>
) : (
<span className="rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] text-rose-700">线</span>
);
}
// ---- 内联图标(无依赖;lucide 风格 stroke 路径)----
type IconName =
| "check" | "alert" | "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db" | "bolt";
const PATHS: Record<IconName, string> = {
check: "M20 6 9 17l-5-5",
alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
bolt: "M13 2 3 14h7l-1 8 10-12h-7l1-8z",
};
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>
);
}
+8
View File
@@ -6,6 +6,7 @@ import { Soon } from "./components/Soon";
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage })));
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
export interface RouteDef {
path: string;
@@ -37,6 +38,13 @@ export const routes: RouteDef[] = [
ready: true,
element: <PricingPage />,
},
{
path: "/status",
label: "服务状态",
group: "运维",
ready: true,
element: <StatusPage />,
},
{
path: "/tenants",
label: "租户",