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 (
);
}
// ── 服务器卡片 ──────────────────────────────────────────────────────────────
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 登录命令 */}
{/* 认证方式 */}
认证方式
{/* 密码认证 */}
{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 配置条目
) : (
)}
{/* 底部操作 */}
导入后原 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)}
/>
)
)}
);
}