Files
opsxdb/src/McpSettings.tsx
T
logan 2c0bfafd8d 同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库: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)与签名私钥仍按规则排除
2026-10-16 03:47:33 +08:00

346 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}