- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { Check, Copy, ExternalLink, RefreshCw } from "lucide-react";
|
||
import {
|
||
getMcpInstall,
|
||
getMcpPolicy,
|
||
saveMcpPolicy,
|
||
type McpInstallInfo,
|
||
type McpPolicy,
|
||
} from "./api";
|
||
import { useWorkspaceStore } from "./store";
|
||
import { openExternal } from "./openExternal";
|
||
|
||
const MCP_DOCS_URL = "http://127.0.0.1:8788/docs/mcp";
|
||
const NPM_PKG = "@rlwj-lab/opsxdb-mcp";
|
||
|
||
type ClientTab = "cursor" | "claude-code" | "vscode" | "windsurf" | "codex" | "claude-desktop";
|
||
|
||
const CLIENT_TABS: Array<{ id: ClientTab; label: string }> = [
|
||
{ id: "cursor", label: "Cursor" },
|
||
{ id: "claude-code", label: "Claude Code" },
|
||
{ id: "vscode", label: "VS Code" },
|
||
{ id: "windsurf", label: "Windsurf" },
|
||
{ id: "codex", label: "Codex" },
|
||
{ id: "claude-desktop", label: "Claude Desktop" },
|
||
];
|
||
|
||
function buildClientSnippet(tab: ClientTab, goBin: string): { path: string; json: string } {
|
||
const env: Record<string, string> = {};
|
||
if (goBin) env.OPSXDB_MCP_BIN = goBin;
|
||
|
||
const npxServer = {
|
||
command: "npx",
|
||
args: ["-y", NPM_PKG],
|
||
...(Object.keys(env).length ? { env } : {}),
|
||
};
|
||
const absServer = goBin
|
||
? { command: goBin, args: [] as string[] }
|
||
: npxServer;
|
||
|
||
switch (tab) {
|
||
case "claude-code":
|
||
return {
|
||
path: "项目根目录 .mcp.json",
|
||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||
};
|
||
case "cursor":
|
||
return {
|
||
path: "~/.cursor/mcp.json 或项目 .cursor/mcp.json",
|
||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||
};
|
||
case "vscode":
|
||
return {
|
||
path: ".vscode/mcp.json",
|
||
json: JSON.stringify(
|
||
{
|
||
servers: {
|
||
opsxdb: {
|
||
type: "stdio",
|
||
command: npxServer.command,
|
||
args: npxServer.args,
|
||
...(npxServer.env ? { env: npxServer.env } : {}),
|
||
},
|
||
},
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
};
|
||
case "windsurf":
|
||
return {
|
||
path: "~/.codeium/windsurf/mcp_config.json",
|
||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||
};
|
||
case "codex":
|
||
return {
|
||
path: "~/.codex/config.toml(MCP 段)或对应 mcp.json",
|
||
json: JSON.stringify({ mcpServers: { opsxdb: absServer } }, null, 2),
|
||
};
|
||
case "claude-desktop":
|
||
return {
|
||
path: "Claude Desktop → Developer → Edit Config",
|
||
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
|
||
};
|
||
}
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||
checking: { label: "检测中", color: "var(--ops-muted)", bg: "var(--ops-surface-2)" },
|
||
ready: { label: "已就绪", color: "var(--ops-accent)", bg: "color-mix(in srgb, var(--ops-accent) 14%, transparent)" },
|
||
partial: { label: "部分就绪", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
|
||
not_installed: { label: "未安装", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
|
||
};
|
||
const m = map[status] ?? map.not_installed;
|
||
return (
|
||
<span
|
||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||
style={{ color: m.color, background: m.bg }}
|
||
>
|
||
{m.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 设置 → MCP:权限状态、授权连接、客户端接入 */
|
||
export function McpSettings() {
|
||
const notify = useWorkspaceStore((s) => s.notify);
|
||
const connections = useWorkspaceStore((s) => s.connections);
|
||
|
||
const [install, setInstall] = useState<McpInstallInfo | null>(null);
|
||
const [policy, setPolicy] = useState<McpPolicy | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [installLoading, setInstallLoading] = useState(true);
|
||
const [installError, setInstallError] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [clientTab, setClientTab] = useState<ClientTab>("cursor");
|
||
const [copied, setCopied] = useState<"install" | "snippet" | "">("");
|
||
const [tick, setTick] = useState(0);
|
||
|
||
const reload = useCallback(() => setTick((n) => n + 1), []);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
setLoading(true);
|
||
setError("");
|
||
getMcpPolicy()
|
||
.then((pol) => {
|
||
if (!alive) return;
|
||
setPolicy(pol.policy);
|
||
})
|
||
.catch((e) => {
|
||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||
})
|
||
.finally(() => {
|
||
if (alive) setLoading(false);
|
||
});
|
||
setInstallLoading(true);
|
||
setInstallError("");
|
||
getMcpInstall()
|
||
.then((inst) => { if (alive) setInstall(inst); })
|
||
.catch((e) => { if (alive) setInstallError(e instanceof Error ? e.message : String(e)); })
|
||
.finally(() => { if (alive) setInstallLoading(false); });
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [tick]);
|
||
|
||
const readOnly = !policy || policy.mode !== "read_write";
|
||
const allowDangerous = !!policy?.allow_dangerous;
|
||
const allowed = new Set(policy?.allowed_connection_ids ?? []);
|
||
|
||
const snippet = useMemo(
|
||
() => buildClientSnippet(clientTab, install?.goBinaryPath || ""),
|
||
[clientTab, install?.goBinaryPath],
|
||
);
|
||
|
||
const persist = async (next: McpPolicy) => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await saveMcpPolicy(next);
|
||
setPolicy(res.policy);
|
||
notify({ kind: "success", title: "MCP", message: "策略已保存", mode: "toast", durationMs: 1400 });
|
||
} catch (e) {
|
||
notify({ kind: "error", title: "MCP", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const copyText = async (text: string, which: "install" | "snippet") => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(which);
|
||
setTimeout(() => setCopied(""), 1600);
|
||
notify({ kind: "success", title: "已复制", message: which === "install" ? "安装命令" : "配置片段", mode: "toast", durationMs: 1200 });
|
||
} catch {
|
||
notify({ kind: "error", title: "复制失败", message: "请手动选中复制", mode: "toast", durationMs: 2000 });
|
||
}
|
||
};
|
||
|
||
const toggleConn = (id: string) => {
|
||
if (!policy) return;
|
||
const set = new Set(policy.allowed_connection_ids ?? []);
|
||
if (set.has(id)) set.delete(id);
|
||
else set.add(id);
|
||
void persist({ ...policy, allowed_connection_ids: Array.from(set) });
|
||
};
|
||
|
||
if (loading) return <p role="status" className="text-xs" style={{ color: "var(--ops-muted)" }}>正在读取 MCP 设置…</p>;
|
||
if (error) {
|
||
return (
|
||
<div role="alert" className="space-y-2 text-xs">
|
||
<p>{error}</p>
|
||
<button type="button" className="ops-btn-secondary" onClick={reload}>重试</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const installCmd = install?.installCommand || `npm install -g ${NPM_PKG}@latest`;
|
||
|
||
return (
|
||
<div className="space-y-5 text-[12.5px]" style={{ color: "var(--ops-text)" }}>
|
||
{/* Header */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-[13px] font-semibold">MCP Server</h3>
|
||
<StatusBadge status={installLoading && !install ? "checking" : install?.status || "not_installed"} />
|
||
<button
|
||
type="button"
|
||
className="ops-btn-ghost ml-auto inline-flex h-6 items-center gap-1 px-2 text-[11px]"
|
||
onClick={() => void openExternal(MCP_DOCS_URL)}
|
||
title="打开 MCP 文档"
|
||
>
|
||
MCP 指南 <ExternalLink className="h-3 w-3" strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
{installError && <p role="alert" className="text-[11px]" style={{ color: "var(--ops-warning, #d97706)" }}>安装检测失败:{installError}。连接白名单与权限设置仍可使用。</p>}
|
||
|
||
{/* Version grid */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{[
|
||
["当前版本", install?.currentVersion || (installLoading ? "检测中…" : install?.goBinaryFound ? "native" : "未检测到全局安装")],
|
||
["最新版本", install?.latestVersion ? `v${install.latestVersion}` : "—"],
|
||
["Node.js", install?.nodeVersion || (installLoading ? "检测中…" : "未检测到")],
|
||
["npm", install?.npmAvailable ? (install.npmVersion || "可用") : installLoading ? "检测中…" : "不可用"],
|
||
].map(([k, v]) => (
|
||
<div key={k} className="rounded-lg border px-2.5 py-2" style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-surface-2)" }}>
|
||
<div className="text-[10px] uppercase tracking-wide" style={{ color: "var(--ops-muted)" }}>{k}</div>
|
||
<div className="mt-0.5 truncate font-mono text-[11.5px]">{v}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{install?.goBinaryPath && (
|
||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||
Go 二进制:<span className="font-mono">{install.goBinaryPath}</span>
|
||
</p>
|
||
)}
|
||
{install?.hint && (
|
||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{install.hint}</p>
|
||
)}
|
||
|
||
{/* Install command */}
|
||
<div className="space-y-1.5">
|
||
<div className="text-[12px] font-medium">安装命令</div>
|
||
<div className="flex gap-1.5">
|
||
<code
|
||
className="min-w-0 flex-1 overflow-x-auto rounded border px-2 py-1.5 font-mono text-[11px]"
|
||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||
>
|
||
{installCmd}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
className="ops-btn-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center"
|
||
aria-label="复制安装命令"
|
||
onClick={() => void copyText(installCmd, "install")}
|
||
>
|
||
{copied === "install" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
|
||
npm 包是 Node 启动器;还需本机 Go 二进制(<code className="font-mono">go build -o bin/opsxdb-mcp ./cmd/opsxdb-mcp</code>),
|
||
并在客户端配置里设置 <code className="font-mono">OPSXDB_MCP_BIN</code>。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||
<span className="rounded-md px-2 py-1" style={{ background: "var(--ops-surface-2)" }}>{readOnly ? "只读模式" : "读写模式"}</span>
|
||
{!readOnly && allowDangerous && <span className="rounded-md px-2 py-1" style={{ background: "var(--ops-surface-2)" }}>允许危险 SQL</span>}
|
||
{(readOnly || !allowDangerous) && <button type="button" className="ops-btn-secondary" disabled={saving || !policy} onClick={() => {
|
||
if (policy) void persist({ ...policy, mode: "read_write", allow_dangerous: true });
|
||
}}>使用默认权限</button>}
|
||
</div>
|
||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>Agent 写入前确认具体命令、连接与数据库。</p>
|
||
</div>
|
||
|
||
{/* Whitelist */}
|
||
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
<div className="text-[12px] font-medium">连接白名单</div>
|
||
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
|
||
仅可访问勾选的连接。
|
||
</p>
|
||
{connections.length === 0 ? (
|
||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>暂无已保存连接。请先在侧栏新建连接。</p>
|
||
) : (
|
||
<div className="max-h-36 space-y-1 overflow-y-auto rounded border p-2" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
{connections.map((c) => (
|
||
<label key={c.id} className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-[color-mix(in_srgb,var(--ops-accent)_8%,transparent)]">
|
||
<input
|
||
type="checkbox"
|
||
checked={allowed.has(c.id)}
|
||
disabled={saving}
|
||
onChange={() => toggleConn(c.id)}
|
||
/>
|
||
<span className="min-w-0 flex-1 truncate">{c.name || c.id}</span>
|
||
<span className="shrink-0 font-mono text-[10px]" style={{ color: "var(--ops-muted)" }}>{c.driver}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Client configs */}
|
||
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
<div className="text-[12px] font-medium">MCP 配置</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{CLIENT_TABS.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
className={`rounded px-2 py-1 text-[11px] ${clientTab === t.id ? "ops-tree-item-active" : "ops-btn-ghost"}`}
|
||
onClick={() => setClientTab(t.id)}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>写入位置:{snippet.path}</p>
|
||
<div className="relative">
|
||
<pre
|
||
className="max-h-48 overflow-auto rounded border p-2 font-mono text-[10.5px] leading-relaxed"
|
||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||
>
|
||
{snippet.json}
|
||
</pre>
|
||
<button
|
||
type="button"
|
||
className="ops-btn-secondary absolute right-2 top-2 inline-flex h-7 items-center gap-1 px-2 text-[11px]"
|
||
onClick={() => void copyText(snippet.json, "snippet")}
|
||
>
|
||
{copied === "snippet" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||
<button type="button" className="ops-btn-secondary inline-flex h-7 items-center gap-1 px-2.5 text-[11px]" onClick={reload} disabled={loading || installLoading}>
|
||
<RefreshCw className="h-3 w-3" /> 重新检查
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|