import { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { getServers, addServer, editServer, deleteServer, getServerProjectLinks, getProjects, getSettings, updateSettings, checkVscodeStatus, openVscodeRemote, openServerTerminal, syncSshConfig, parseSshConfigEntries, importSshConfigEntries, type Server, type SaveServerInput, type QuickLink, type SshConfigEntry, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; import { ServiceConfigPanel } from "./ServiceConfigBlock"; import { HostAppsPanel } from "./HostAppsPanel"; function CopyBtn({ value }: { value: string }) { const [copied, setCopied] = useState(false); const handleCopy = async () => { await navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return ( ); } function buildSshCommand(s: Server): string { const port = s.server_port && s.server_port !== "22" ? ` -p ${s.server_port}` : ""; const key = s.ssh_key_path ? ` -i ${s.ssh_key_path}` : ""; const user = s.username ? `${s.username}@` : ""; return `ssh ${user}${s.server_host}${port}${key}`; } function buildDeployKeyCommand(s: Server, globalKeyPath?: string): string { const rawKey = s.ssh_key_path || globalKeyPath || ""; const keyPub = rawKey ? rawKey.replace(/(?:\.pub)?$/, ".pub") : "%USERPROFILE%\\.ssh\\id_ed25519.pub"; const portFlag = s.server_port && s.server_port !== "22" ? ` -p ${s.server_port}` : ""; const user = s.username ? `${s.username}@` : ""; return `type ${keyPub} | ssh${portFlag} ${user}${s.server_host} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`; } // ── SSH 密钥配置区域 ──────────────────────────────────────────────────────── function SshKeySetup({ keyPath, onKeyPathChange }: { keyPath: string; onKeyPathChange: (path: string) => void; }) { const [showGuide, setShowGuide] = useState(false); const handleBrowse = async () => { const selected = await openFileDialog({ title: "选择 SSH 私钥文件", defaultPath: "C:\\Users", filters: [ { name: "SSH Key", extensions: ["pem", "key", "*"] }, { name: "所有文件", extensions: ["*"] }, ], multiple: false, directory: false, }); if (selected) { onKeyPathChange(selected as string); } }; const hasKey = !!keyPath; return (
SSH 密钥配置 {hasKey ? ( 已配置 ) : ( 未配置 )}
{/* 密钥路径选择 */}
onKeyPathChange(e.target.value)} placeholder="例: C:\Users\你的用户名\.ssh\id_ed25519" className="text-xs px-2.5 py-2 rounded-md border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 flex-1 font-mono" />
{hasKey && (
公钥路径 {keyPath.replace(/(?:\.pub)?$/, ".pub")}
)} {/* 生成教程 */}
setShowGuide((e.target as HTMLDetailsElement).open)}> ▶ {hasKey ? "查看密钥生成教程" : "还没有密钥?点击查看如何生成"}

第 1 步:生成密钥对

在本地 CMD 或 PowerShell 中执行(ed25519 是加密算法名称,目前最推荐的算法,安全性高且密钥短):

ssh-keygen -t ed25519

连按三次回车使用默认路径和空密码。一套密钥对可用于所有服务器。生成后会得到两个文件:

  • 私钥:C:\Users\你的用户名\.ssh\id_ed25519(自己留着)
  • 公钥:C:\Users\你的用户名\.ssh\id_ed25519.pub(放到服务器)

第 2 步:选择私钥

点击上方「浏览…」按钮选择刚生成的私钥文件,路径会自动保存。

第 3 步:部署公钥到服务器

配置好密钥后,每台服务器卡片下方会出现「部署公钥」命令,复制到 CMD 执行一次即可免密登录。

); } // ── 服务器表单 ────────────────────────────────────────────────────────────── function ServerForm({ initial, onSave, onCancel, saving }: { initial?: Server; onSave: (input: SaveServerInput) => void; onCancel: () => void; saving: boolean; }) { const [name, setName] = useState(initial?.name ?? ""); const [host, setHost] = useState(initial?.server_host ?? ""); const [port, setPort] = useState(initial?.server_port ?? "22"); const [username, setUsername] = useState(initial?.username ?? ""); const [password, setPassword] = useState(initial?.password ?? ""); const [sshKeyPath, setSshKeyPath] = useState(initial?.ssh_key_path ?? ""); const [notes, setNotes] = useState(initial?.notes ?? ""); const [quickLinks, setQuickLinks] = useState(() => { try { return initial?.quick_links ? JSON.parse(initial.quick_links) : []; } catch { return []; } }); const addLink = () => setQuickLinks((p) => [...p, { label: "", url: "" }]); const removeLink = (i: number) => setQuickLinks((p) => p.filter((_, idx) => idx !== i)); const updateLink = (i: number, f: "label" | "url", v: string) => setQuickLinks((p) => p.map((l, idx) => (idx === i ? { ...l, [f]: v } : l))); const handleSubmit = () => { if (!name.trim() || !host.trim()) return; onSave({ name: name.trim(), server_host: host.trim(), server_port: port || undefined, username: username || undefined, password: password || undefined, ssh_key_path: sshKeyPath || undefined, quick_links: quickLinks.filter((l) => l.url.trim()).length > 0 ? JSON.stringify(quickLinks.filter((l) => l.url.trim())) : undefined, notes: notes || undefined, }); }; return (
setName(e.target.value)} placeholder="例: 生产服务器A" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" /> setHost(e.target.value)} placeholder="例: 192.168.1.100" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" /> setPort(e.target.value)} placeholder="22" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400 w-24" /> setUsername(e.target.value)} placeholder="例: root" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" /> setPassword(e.target.value)} type="password" placeholder="选填" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" /> setSshKeyPath(e.target.value)} placeholder="例: ~/.ssh/id_rsa" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" /> setNotes(e.target.value)} placeholder="例: 阿里云 ECS" className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
{/* 快速链接 */}
快速链接
{quickLinks.map((link, idx) => (
updateLink(idx, "label", e.target.value)} placeholder="名称" className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50 w-24 focus:outline-none focus:border-gray-400" /> updateLink(idx, "url", e.target.value)} placeholder="https://..." className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50 flex-1 focus:outline-none focus:border-gray-400" />
))}
); } // ── 服务器卡片 ────────────────────────────────────────────────────────────── function ServerCard({ server, onEdit, onDelete, globalKeyPath }: { server: Server; onEdit: () => void; onDelete: () => void; globalKeyPath?: string; }) { const [showPwd, setShowPwd] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [collapsed, setCollapsed] = useState(true); const sshCmd = buildSshCommand(server); const links: QuickLink[] = (() => { try { return server.quick_links ? JSON.parse(server.quick_links) : []; } catch { return []; } })(); // 关联的项目 const { data: linkedProjects = [] } = useQuery({ queryKey: ["server-projects", server.id], queryFn: () => getServerProjectLinks(server.id), }); const { data: allProjects = [] } = useQuery({ queryKey: ["projects"], queryFn: () => getProjects(), staleTime: 30000, }); const keyPath = server.ssh_key_path || globalKeyPath; return (
{/* ── 标题栏 ── */}
{server.name.charAt(0).toUpperCase()}
{server.name} {server.notes && ( {server.notes} )}
{server.server_host}{server.server_port && server.server_port !== "22" ? `:${server.server_port}` : ""}
{!confirmDelete ? ( ) : (
)}
{/* ── 内容区:左右两栏布局 ── */} {!collapsed &&
{/* 左栏:服务器信息 + 快速链接 + 关联项目 */}
{/* 服务器信息 */}

服务器信息

主机 {server.server_host}
{server.server_port && (
端口 {server.server_port}
)} {server.username && (
用户 {server.username}
)}
{/* 快速链接 */} {links.length > 0 && (

快速链接

{links.map((link, i) => ( ))}
)} {/* 关联项目 */} {linkedProjects.length > 0 && (

关联项目

{linkedProjects.map((lp) => { const proj = allProjects.find((p) => p.id === lp.project_id); return ( {proj?.name ?? lp.project_id} {lp.role && ( {lp.role} )} ); })}
)}
{/* 右栏:连接配置 */}
{/* SSH 登录命令 */}

SSH 登录

$ {sshCmd}
{/* 认证方式 */}

认证方式

{/* 密码认证 */}
密码认证
{server.password ? (
{showPwd ? server.password : "••••••••"}
) : (

未设置密码

)}
{/* 密钥认证 */}
密钥认证 免密
{keyPath ? (
{keyPath} {!server.ssh_key_path && globalKeyPath && ( 全局 )}
) : (

请先在顶部配置 SSH 密钥

)} {/* 部署公钥 */}
▶ 部署公钥到此服务器
> {buildDeployKeyCommand(server, globalKeyPath)}

复制到 CMD 执行一次,输入密码后即可免密登录。

{/* VS Code Remote */} {/* 宿主机应用 */} {/* 服务连接配置(Linux 客户端层) */} {/* Docker 应用配置 */}
}
); } // ── VS Code Remote 区块 ───────────────────────────────────────────────────── function VscodeRemoteBlock({ serverId, serverName }: { serverId: string; serverName: string }) { const showToast = useUIStore((s) => s.showToast); const [remotePath, setRemotePath] = useState(""); const [syncing, setSyncing] = useState(false); const [openingTerm, setOpeningTerm] = useState(false); const { data: vscodeStatus } = useQuery({ queryKey: ["vscode-status"], queryFn: checkVscodeStatus, staleTime: 60000, }); const handleOpen = async () => { try { setSyncing(true); await openVscodeRemote(serverId, remotePath || undefined); showToast(`VS Code Remote 已打开: ${serverName}`); } catch (e) { showToast(`${e}`); } finally { setSyncing(false); } }; const handleOpenTerminal = async () => { try { setOpeningTerm(true); await openServerTerminal(serverId); showToast(`终端已打开: ${serverName}`); } catch (e) { showToast(`${e}`); } finally { setOpeningTerm(false); } }; const handleSyncOnly = async () => { try { setSyncing(true); const path = await syncSshConfig(); showToast(`SSH Config 已同步: ${path}`); } catch (e) { showToast(`${e}`); } finally { setSyncing(false); } }; const installed = vscodeStatus?.installed ?? false; return (

快捷操作

{/* 状态提示 */} {!installed && (
!

VS Code 未检测到

请在炼境「设置」页面配置 VS Code 编辑器路径,或确保 code 命令在系统 PATH 中。

)} {installed && (
i

需要 VS Code 扩展 Remote - SSH(ms-vscode-remote.remote-ssh)。打开 VS Code → 扩展 → 搜索安装。

)} {/* 远程路径 + 打开按钮 */}
setRemotePath(e.target.value)} placeholder="远程路径(默认 /root)" className="text-xs px-2.5 py-2 rounded-md border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 flex-1 font-mono" />
{/* CMD 打开 */}
在 Windows Terminal 中 SSH 连接到此服务器
{/* 同步 SSH Config */}

炼境会自动同步服务器信息到 ~/.ssh/config

); } // ── 主面板 ────────────────────────────────────────────────────────────────── // ── 导入 SSH Config 弹窗 ──────────────────────────────────────────────────── function ImportSshConfigModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) { const showToast = useUIStore((s) => s.showToast); const [entries, setEntries] = useState([]); const [selected, setSelected] = useState>(new Set()); const [loading, setLoading] = useState(true); const [importing, setImporting] = useState(false); useEffect(() => { parseSshConfigEntries() .then((list) => { setEntries(list); setSelected(new Set(list.map((_, i) => i))); // 默认全选 }) .catch((e) => showToast(`解析失败: ${e}`)) .finally(() => setLoading(false)); }, []); const toggleSelect = (idx: number) => { setSelected((prev) => { const next = new Set(prev); next.has(idx) ? next.delete(idx) : next.add(idx); return next; }); }; const toggleAll = () => { if (selected.size === entries.length) { setSelected(new Set()); } else { setSelected(new Set(entries.map((_, i) => i))); } }; const handleImport = async () => { const toImport = entries.filter((_, i) => selected.has(i)); if (toImport.length === 0) return; setImporting(true); try { const count = await importSshConfigEntries(toImport); showToast(`已导入 ${count} 台服务器,SSH Config 已同步`); onImported(); onClose(); } catch (e) { showToast(`导入失败: ${e}`); } finally { setImporting(false); } }; return (
e.stopPropagation()}> {/* 标题 */}

从 SSH Config 导入

检测到以下手写的 SSH 配置,勾选后导入到炼境统一管理

{/* 列表 */}
{loading ? (

解析中…

) : entries.length === 0 ? (

未检测到可导入的 SSH 配置条目

) : (
{/* 全选 */} {entries.map((entry, idx) => ( ))}
)}
{/* 底部操作 */}

导入后原 SSH Config 手写条目将由炼境接管

); } // ── 主面板 ────────────────────────────────────────────────────────────────── export function ServerManagerPanel() { const qc = useQueryClient(); const [showForm, setShowForm] = useState(false); const [showImport, setShowImport] = useState(false); const [editingServer, setEditingServer] = useState(null); const [sshKeyPath, setSshKeyPath] = useState(""); // 从 settings 读取全局 SSH 密钥路径 const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: () => getSettings(), }); useEffect(() => { if (settings?.ssh_key_path) { setSshKeyPath(settings.ssh_key_path); } }, [settings]); const handleKeyPathChange = async (path: string) => { setSshKeyPath(path); try { await updateSettings({ ssh_key_path: path }); qc.invalidateQueries({ queryKey: ["settings"] }); } catch { /* ignore */ } }; const { data: servers = [] } = useQuery({ queryKey: ["servers"], queryFn: getServers, }); const addMut = useMutation({ mutationFn: (input: SaveServerInput) => addServer(input), onSuccess: () => { qc.invalidateQueries({ queryKey: ["servers"] }); setShowForm(false); }, }); const editMut = useMutation({ mutationFn: ({ id, input }: { id: string; input: SaveServerInput }) => editServer(id, input), onSuccess: () => { qc.invalidateQueries({ queryKey: ["servers"] }); setEditingServer(null); }, }); const deleteMut = useMutation({ mutationFn: (id: string) => deleteServer(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["servers"] }), }); return (
{/* SSH 密钥配置 */} {/* 顶部操作栏 */}

服务器管理

{servers.length} 台
{!showForm && !editingServer && (
)}
{/* 导入弹窗 */} {showImport && ( setShowImport(false)} onImported={() => qc.invalidateQueries({ queryKey: ["servers"] })} /> )} {/* 新增表单 */} {showForm && ( addMut.mutate(input)} onCancel={() => setShowForm(false)} saving={addMut.isPending} /> )} {/* 服务器列表 */} {servers.length === 0 && !showForm && (

🖥️

还没有服务器,点击上方「添加服务器」或「从 SSH Config 导入」

服务器信息可被多个项目共享关联

)} {servers.map((s) => editingServer?.id === s.id ? ( editMut.mutate({ id: s.id, input })} onCancel={() => setEditingServer(null)} saving={editMut.isPending} /> ) : ( setEditingServer(s)} onDelete={() => deleteMut.mutate(s.id)} /> ) )}
); }