From ec0f3c22f5405171c16ddf0b1cd62d6cd58adcf5 Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Tue, 15 Sep 2026 17:16:03 +0800 Subject: [PATCH] feat(tools): add MCP connection health checks --- app/tools/[id]/page.tsx | 165 ++++++++++- app/tools/page.tsx | 374 +++++++++++++++++++++--- hooks/useTools.ts | 81 ++++- messages/en/tools.json | 27 +- messages/zh-CN/tools.json | 27 +- pages/api/tools/[id]/test-connection.ts | 60 ++++ test/toolFootprint.test.ts | 71 ++++- 7 files changed, 740 insertions(+), 65 deletions(-) create mode 100644 pages/api/tools/[id]/test-connection.ts diff --git a/app/tools/[id]/page.tsx b/app/tools/[id]/page.tsx index 24e6ae9..5a0d724 100644 --- a/app/tools/[id]/page.tsx +++ b/app/tools/[id]/page.tsx @@ -31,11 +31,21 @@ import { XCircle, Activity, Maximize2, + PlugZap, + CircleCheck, + CircleAlert, + CircleHelp, } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useTool } from "@/hooks/useTools"; +import { + useTool, + testToolConnection, + Tool, + ToolConnectionTestResult, + formatTokens, +} from "@/hooks/useTools"; import { useCurrentUser } from "@/hooks/useCurrentUser"; -import { checkSuperAdmin } from "@/lib/clientPermissions"; +import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions"; import { useToolExecutions, useToolStatistics } from "@/hooks/useToolExecutions"; export default function ToolDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -50,10 +60,17 @@ export default function ToolDetailPage({ params }: { params: Promise<{ id: strin const [detailType, setDetailType] = useState<"input" | "output">("input"); const [detailContent, setDetailContent] = useState(null); const [detailTitle, setDetailTitle] = useState(""); + // 体检:结果就地展示(详情页有位置说清原因,不需要弹 toast), + // 完成后刷新工具详情,让"连接状态"这一块跟着更新 + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); const { user } = useCurrentUser(); const isSuperAdmin = checkSuperAdmin(user); - const { tool, loading: toolLoading } = useTool(toolId, true, isSuperAdmin); + // 体检端点要的是"超管或租户管理员"(与工具列表页同一判据)—— + // 只按超管显示按钮的话,租户管理员在这儿会看不到这个功能 + const canManageTools = isSuperAdmin || checkTenantAdmin(user); + const { tool, loading: toolLoading, refresh: refreshTool } = useTool(toolId, true, isSuperAdmin); const { executions, total, @@ -93,6 +110,18 @@ export default function ToolDetailPage({ params }: { params: Promise<{ id: strin setDetailDialogOpen(true); }; + const handleTestConnection = async () => { + if (!tool) return; + setTesting(true); + try { + const result = await testToolConnection(tool.id); + setTestResult(result); + if (result) refreshTool(); + } finally { + setTesting(false); + } + }; + const formatContent = (content: any): string => { if (content === null || content === undefined) { return "-"; @@ -211,6 +240,92 @@ export default function ToolDetailPage({ params }: { params: Promise<{ id: strin
{tool.is_system ? t("yes") : t("no")}
+ {/* 连接状态。只有 MCP 工具有"连得上吗"这回事 —— native / workflow 不建立连接。 + 这一块存在的理由:注册是按需的(启动时不连),从没被用过的工具在 + 列表页什么都看不出来,而"这条连接还能不能用"原先只能去聊天里发句话试。 */} + {tool.tool_type === "mcp" && ( +
+
{t("connectionStatus")}
+
+ + {tool.footprint?.status === "ok" && ( + + {t("footprintSummary", { + count: tool.footprint.subtool_count, + tokens: formatTokens(tool.footprint.estimated_tokens), + })} + {tool.footprint.checked_at + ? ` · ${t("connOkCheckedHint", { + time: new Date(tool.footprint.checked_at * 1000).toLocaleString(), + })}` + : ` · ${t("connOkHint")}`} + + )} + {canManageTools && ( + + )} +
+ + {/* 失败/未配置的原因:正文位置说清楚,别塞进 tooltip —— 这是用户来这一页 + 要找的东西。四档分开说,unconfigured 要改配置、failed 要查对端。 */} + {tool.footprint && + (tool.footprint.status === "failed" || + tool.footprint.status === "unconfigured") && ( +
+ {tool.footprint.status === "failed" + ? t("connFailedHint") + : t("connUnconfiguredHint")} + {tool.footprint.error ? ` ${tool.footprint.error}` : ""} +
+ )} + + {/* 刚做完的那次体检结果。与上面那块(注册表里已有的结论)分开显示: + 一次体检可能改了状态,而工具详情是刷新后才回来的,两者短暂并存。 */} + {testResult && ( +
+ {testResult.status === "ok" && + t("testOk", { + name: tool.display_name, + count: testResult.subtool_count, + tokens: formatTokens(testResult.estimated_tokens), + ms: testResult.duration_ms, + })} + {testResult.status === "failed" && + t("testFailed", { + name: tool.display_name, + reason: testResult.error || "-", + })} + {testResult.status === "unconfigured" && + t("testUnconfigured", { + name: tool.display_name, + reason: testResult.error || "-", + })} + {testResult.status === "not_applicable" && + t("testNotApplicable", { name: tool.display_name })} +
+ )} +
+ )} +
{t("description")}
{tool.description}
@@ -503,3 +618,47 @@ export default function ToolDetailPage({ params }: { params: Promise<{ id: strin
); } + +/** + * 详情页的连接状态徽标。 + * + * 与列表页共用同一套文案键与同一套四档判据 —— 两处口径必须一致,否则同一个工具 + * 会出现"列表显示未配置、点进来显示未验证"这种只能靠逐个核对才发现的分歧。 + * + * 四档为什么要分开:`unconfigured`(配置没填完,后端连试都没试)要去**改配置**, + * `failed`(配置完整但连不上)要去**查对端或网络**,未验证(从没被用过)什么都不用做。 + */ +function ConnectionStateBadge({ footprint }: { footprint?: Tool["footprint"] }) { + const t = useTranslations("tools"); + + if (!footprint) { + return ( + + + {t("connUntested")} + + ); + } + if (footprint.status === "unconfigured") { + return ( + + + {t("connUnconfigured")} + + ); + } + if (footprint.status === "failed") { + return ( + + + {t("connFailed")} + + ); + } + return ( + + + {t("connOk")} + + ); +} diff --git a/app/tools/page.tsx b/app/tools/page.tsx index f7d2b69..dbd7b36 100644 --- a/app/tools/page.tsx +++ b/app/tools/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { checkSuperAdmin, checkTenantAdmin } from "@/lib/clientPermissions"; import { useBuiltinTools } from "@/hooks/useBuiltinTools"; @@ -17,6 +17,7 @@ import { TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Select, SelectContent, @@ -35,9 +36,23 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Switch } from "@/components/ui/switch"; -import { Plus, Edit, Trash2, Eye, Loader2, Wrench, Code, Globe } from "lucide-react"; +import { + Plus, + Edit, + Trash2, + Eye, + Loader2, + Wrench, + Code, + Globe, + PlugZap, + CircleCheck, + CircleAlert, + CircleHelp, +} from "lucide-react"; import { useRouter } from "next/navigation"; -import { useTools, Tool } from "@/hooks/useTools"; +import { toast } from "sonner"; +import { useTools, Tool, ToolConnectionTestResult, formatTokens } from "@/hooks/useTools"; import { ToolFormDialog } from "./components/ToolFormDialog"; // 获取工具类型图标(兜底图标) @@ -91,6 +106,11 @@ export default function ToolsPage() { const [selectedTool, setSelectedTool] = useState(null); const [formDialogOpen, setFormDialogOpen] = useState(false); const [editingTool, setEditingTool] = useState(null); + // 正在体检的工具 id。用 Set 而不是单个 id:批量体检时会有多行同时在转。 + const [testingIds, setTestingIds] = useState>(new Set()); + // 批量体检进度。running 只用来禁按钮;done/total 是给用户"还剩多少"的读数 —— + // 一页 20 个 MCP 工具、每个最慢 20s,没有进度时界面看起来和卡死没区别。 + const [bulkTest, setBulkTest] = useState({ running: false, done: 0, total: 0 }); const { user } = useCurrentUser(); const isSuperAdmin = checkSuperAdmin(user); @@ -99,12 +119,21 @@ export default function ToolsPage() { // 就能改能删。按钮留给点不动的人,等于把 403 当交互。 const canManageTools = isSuperAdmin || checkTenantAdmin(user); - const { tools, total, loading, createTool, updateTool, deleteTool, toggleToolEnabled, refresh } = - useTools({ - is_enabled: isEnabled, - page, - page_size: 20, - }); + const { + tools, + total, + loading, + createTool, + updateTool, + deleteTool, + toggleToolEnabled, + testConnection, + refresh, + } = useTools({ + is_enabled: isEnabled, + page, + page_size: 20, + }); const { builtins, meta: builtinMeta, @@ -150,6 +179,121 @@ export default function ToolsPage() { } }; + const markTesting = (id: number, on: boolean) => + setTestingIds((prev) => { + const next = new Set(prev); + if (on) next.add(id); + else next.delete(id); + return next; + }); + + /** + * 体检结果的播报。 + * + * 光刷新列表是不够的:用户点了按钮之后如果只看到"什么都没发生"(未验证的工具 + * 体检失败后名字下方那行小字也不显眼),他会再点一次。所以结论必须弹出来, + * 而且**四档分开说** —— unconfigured 是"去改配置",failed 是"查对端或网络", + * 两者都报成"连接失败"会让人去排查一个不存在的问题。 + */ + const reportTestResult = (tool: Tool, result: ToolConnectionTestResult | null) => { + if (!result) { + toast.error(t("testRequestFailed")); + return; + } + switch (result.status) { + case "ok": + toast.success( + t("testOk", { + name: tool.display_name, + count: result.subtool_count, + tokens: formatTokens(result.estimated_tokens), + ms: result.duration_ms, + }) + ); + break; + case "failed": + toast.error(t("testFailed", { name: tool.display_name, reason: result.error || "-" })); + break; + case "unconfigured": + toast.warning( + t("testUnconfigured", { name: tool.display_name, reason: result.error || "-" }) + ); + break; + default: + toast.info(t("testNotApplicable", { name: tool.display_name })); + } + }; + + const handleTest = async (tool: Tool) => { + markTesting(tool.id, true); + try { + reportTestResult(tool, await testConnection(tool.id)); + } finally { + markTesting(tool.id, false); + } + }; + + /** + * 批量体检本页的 MCP 工具。 + * + * 并发上限 4:一次全发出去的话,每行都在 20s 超时窗口里,对端被同时敲 20 次, + * 而本机那个 aiohttp/httpx 连接池也会被打满 —— 结果是一片假超时。 + */ + const handleTestAll = async () => { + const targets = tools.filter((x) => x.tool_type === "mcp"); + if (targets.length === 0) { + toast.info(t("testAllNone")); + return; + } + + setBulkTest({ running: true, done: 0, total: targets.length }); + const results: { tool: Tool; result: ToolConnectionTestResult | null }[] = []; + const queue = [...targets]; + + const worker = async () => { + for (let tool = queue.shift(); tool; tool = queue.shift()) { + markTesting(tool.id, true); + try { + const result = await testConnection(tool.id); + results.push({ tool, result }); + } catch { + results.push({ tool, result: null }); + } finally { + markTesting(tool.id, false); + setBulkTest((prev) => ({ ...prev, done: prev.done + 1 })); + } + } + }; + + try { + await Promise.all(Array.from({ length: Math.min(4, targets.length) }, () => worker())); + } finally { + setBulkTest({ running: false, done: 0, total: 0 }); + } + + const ok = results.filter((r) => r.result?.status === "ok"); + const bad = results.filter((r) => r.result && r.result.status !== "ok"); + const unknown = results.filter((r) => !r.result); + // 逐条弹 20 个 toast 会把屏幕刷满,所以汇总成一条并点名前几个 —— + // 要找细节可将焦点放到名字下方的连接状态上查看。 + if (bad.length === 0 && unknown.length === 0) { + toast.success(t("testAllOk", { count: ok.length })); + } else { + toast.error( + t("testAllFailed", { + ok: ok.length, + failed: bad.length, + unknown: unknown.length, + names: bad + .concat(unknown) + .slice(0, 3) + .map((r) => r.tool.display_name) + .join("、"), + }) + ); + } + }; + const getCategoryBadge = (category: string) => { const colors = { search: "bg-green-100 text-green-800", @@ -204,6 +348,27 @@ export default function ToolsPage() { )} + {tab === "managed" && canManageTools && ( + + )} + {tab === "managed" && (