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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "租户",
|
||||
|
||||
Reference in New Issue
Block a user