diff --git a/README.md b/README.md new file mode 100644 index 0000000..765ce95 --- /dev/null +++ b/README.md @@ -0,0 +1,491 @@ +--- +AIGC: + ContentProducer: '001191110102MAD55U9H0F10002' + ContentPropagator: '001191110102MAD55U9H0F10002' + Label: '1' + ProduceID: '1148f2f2-715c-4f80-80d7-010cab5af4b0' + PropagateID: '1148f2f2-715c-4f80-80d7-010cab5af4b0' + ReservedCode1: '57f1878c-c8f1-41ec-9020-31dae82dcead' + ReservedCode2: '57f1878c-c8f1-41ec-9020-31dae82dcead' +--- + +# WFJ Panel 项目说明文档 + +## 一、项目概述 + +WFJ Panel 是一款基于 Electron + React + ssh2 构建的**远程服务器管理面板**桌面应用,通过 SSH 协议远程连接 Linux 服务器,提供服务器概览监控、文件管理、交互式终端、Docker 容器管理、应用商店一键部署、防火墙管理等功能。 + +- **产品名称**:WFJ Panel +- **版本**:v0.0.1 +- **许可**:MIT +- **构建产物**:NSIS 安装程序(Windows x64,约 107MB) + +--- + +## 二、技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 桌面框架 | Electron 44 + electron-vite 5 | 跨平台桌面应用,Vite 驱动构建 | +| 前端框架 | React 19 | 函数组件 + Hooks | +| 终端模拟 | @xterm/xterm 5 + @xterm/addon-fit | 交互式终端渲染 | +| SSH 通信 | ssh2 1.17 | 纯 JS 实现的 SSH2 客户端 | +| 打包工具 | electron-builder 26 | NSIS 安装包 | +| 后端服务 | Flask 3 + mysql-connector-python | 应用商店 API 服务 | +| 数据库 | MySQL 8 | 应用商店模板与版本数据 | + +--- + +## 三、项目结构 + +``` +ssh_panel/ +├── package.json # 项目元数据、依赖、构建配置 +├── electron.vite.config.mjs # electron-vite 配置(main/preload/renderer 三入口) +├── .npmrc # Electron 国内镜像配置 +├── .gitignore +├── build/ +│ └── icon.png # 应用图标 (256x256) +├── src/ +│ ├── main/ +│ │ └── index.js # ★ 主进程(1377行)—— SSH 连接、IPC、Docker、部署、SFTP、终端 +│ ├── preload/ +│ │ └── index.js # ★ Preload 桥接层 —— 暴露 window.panel API(90行) +│ └── renderer/ +│ ├── index.html # HTML 入口 +│ └── src/ +│ ├── main.jsx # React 挂载入口 +│ ├── App.jsx # ★ 根组件 —— 登录/面板切换、连接状态管理 +│ ├── theme.css # 设计令牌(色彩/间距/阴影/按钮体系) +│ ├── main.css # 全局样式 +│ └── pages/ +│ ├── Login.jsx # SSH 登录页(凭据保存/自动填充) +│ ├── Panel.jsx # ★ 主面板(导航栏 + 功能模块切换 + 版本更新) +│ ├── overview/ # 服务器概览监控 +│ │ ├── Overview.jsx +│ │ ├── useMetric.js # 通用指标采集 Hook +│ │ └── cards/ # CPU/内存/硬盘/网络/进程 五张卡片 +│ ├── files/ # SFTP 远程文件管理 +│ │ └── FileManager.jsx +│ ├── terminal/ # 交互式终端 +│ │ └── TerminalPanel.jsx +│ ├── container/ # Docker 容器管理 +│ │ ├── ContainerPanel.jsx # 容器面板入口(状态检测+子菜单) +│ │ ├── ContainerList.jsx # 容器列表(启动/停止/删除/日志) +│ │ ├── ImageList.jsx # 镜像列表 +│ │ ├── AppStore.jsx # ★ 应用商店(在线模板+一键部署) +│ │ ├── TaskCenter.jsx # 后台部署任务中心 +│ │ ├── DockerConfig.jsx # Docker 配置文件查看/编辑 +│ │ ├── NetworkPanel.jsx # Docker 网络管理 +│ │ ├── VolumePanel.jsx # Docker 卷管理 +│ │ ├── ContainerCreate.jsx # 手动创建容器 +│ │ ├── DockerLog.jsx # 容器日志查看 +│ │ ├── cmdParser.js # docker run 命令解析 +│ │ └── appTemplates.js # 内置应用模板(离线备用) +│ └── firewall/ # 系统防火墙管理 +│ └── Firewall.jsx +├── appstore-server/ # 应用商店后端服务 +│ ├── app.py # Flask API(/api/apps、/api/version、/api/health) +│ ├── init.sql # 数据库建表 + 初始数据(Redis/MySQL/Halo 模板) +│ ├── requirements.txt # Python 依赖 +│ ├── Dockerfile # API 服务容器化 +│ └── deploy.sh # 一键部署脚本 +└── release/ # 打包产物输出目录(.gitignore) +``` + +--- + +## 四、架构设计 + +### 4.1 Electron 三层架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 渲染进程 (Renderer) │ +│ React 19 + xterm.js + CSS │ +│ ┌──────────┬──────────┬──────────┬────────┬──────────┐ │ +│ │ 概览监控 │ 文件管理 │ 交互终端 │ Docker │ 防火墙 │ │ +│ └─────┬────┴─────┬────┴─────┬────┴───┬────┴─────┬────┘ │ +│ │ │ │ │ │ │ +│ └──────────┴──────────┴────────┴──────────┘ │ +│ window.panel.* │ +├─────────────────────────────────────────────────────────┤ +│ Preload (桥接层) │ +│ contextBridge.exposeInMainWorld │ +│ ipcRenderer.invoke / on │ +├─────────────────────────────────────────────────────────┤ +│ 主进程 (Main) │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ SSH 连接管理 (ssh2 Client) │ │ +│ │ ┌──────────┬──────────┬───────────┐ │ │ +│ │ │ 主连接 │ Docker │ 部署连接 │ │ │ +│ │ │ (exec + │ 专用连接 │ 专用连接 │ │ │ +│ │ │ sftp + │ (独立队列)│ (后台任务) │ │ │ +│ │ │ shell) │ │ │ │ │ +│ │ └──────────┴──────────┴───────────┘ │ │ +│ │ 串行队列 (execQueue) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ IPC Handlers │ +│ ssh / cred / sftp / shell / │ +│ docker / appstore / app │ +├─────────────────────────────────────────────────────────┤ +│ 应用商店后端 (appstore-server) │ +│ Flask API ←→ MySQL 8 │ +│ /api/apps · /api/version · /api/health │ +└─────────────────────────────────────────────────────────┘ +``` + +### 4.2 三条 SSH 连接设计 + +这是本项目的核心架构决策,通过连接隔离避免不同模块互相阻塞: + +| 连接 | 变量名 | 用途 | 特点 | +|------|--------|------|------| +| **主连接** | `activeConnection` | 概览监控、SFTP 文件管理、交互式终端 | 登录时建立;exec + sftp 共用串行队列 | +| **Docker 连接** | `dockerConnection` | Docker 容器/镜像/网络/卷管理 | 登录时自动建立;独立串行队列 | +| **部署连接** | `deployConnection` | 应用商店后台部署任务 | 懒创建(首次部署时建立);直接并发 exec | + +**串行队列机制**: +- 主连接和 Docker 连接各有独立的串行队列(`execQueue` / `dockerQueue`) +- 同一连接同一时刻只执行一个 channel,避免并发打开过多 channel 导致 `Channel open failure: open failed` 错误 +- 命令轻量(如 `free -m`),串行执行几乎无感知延迟 +- 部署连接不串行(部署任务内部已 `await` 顺序执行,任务间可并行) + +### 4.3 安全设计 + +- **contextIsolation: true**:渲染进程与 Node.js 隔离,无法直接 `require` +- **nodeIntegration: false**:渲染进程无 Node.js 权限 +- **sandbox: false**:preload 可用 Node.js(暴露受控 API) +- **密码加密存储**:使用 Electron `safeStorage` API(Windows 上为 DPAPI 加密),不可用时降级 base64 +- **API 鉴权**:应用商店 API 通过 `Authorization: Bearer ` 校验 + +--- + +## 五、功能模块详解 + +### 5.1 登录与凭据管理 + +- SSH 连接表单(host / port / username / password) +- 「记住密码」:连接成功后加密保存到本机 `userData/credentials.json` +- 已保存服务器列表:点击快速填充、删除 +- 打开时自动预填最近一次保存的凭据 + +### 5.2 服务器概览监控 + +五张实时卡片,每张卡片独立轮询: + +| 卡片 | 数据源命令 | 刷新间隔 | +|------|-----------|----------| +| CPU | `top -bn1` / `mpstat` / `vmstat` | 2s | +| 内存 | `free -m` | 3s | +| 硬盘 | `df -h` | 15s | +| 网络 | `/proc/net/dev` 两次采样计算速率 | 2s | +| 进程 | `ps aux` + `ss -tlnp`/`netstat -tlnp`(监听端口) | 5s | + +- **依赖检测**:`useMetric` Hook 检测命令是否存在(如 `sysstat` 未安装时卡片置灰,提供一键安装按钮) +- **CPU% / 内存% 点击排序**(降序,表头高亮箭头) +- 进程卡片显示监听端口(蓝色徽章) + +### 5.3 SFTP 文件管理 + +- 远程目录浏览(目录优先排序) +- 文件上传(支持多选,实时进度推送 `sftp:progress`) +- 文件下载(单文件保存对话框,批量下载到指定目录) +- 新建远程目录 +- 删除(统一用 `rm -rf`,路径用单引号包裹防止注入) +- SFTP 会话缓存复用(同一个 channel) + +### 5.4 交互式终端 + +- 基于 xterm.js + ssh2 PTY 实现 +- 深色主题(`#1a1d29` 背景,靛蓝光标) +- 支持窗口大小自适应(FitAddon) +- 切换导航/断开连接/切换服务器/关窗时自动清理 Shell +- React StrictMode 双挂载防护(`setTimeout(0)` + `disposed` 标记) + +### 5.5 Docker 容器管理 + +子菜单包含 8 个功能页: + +| 子菜单 | 功能 | +|--------|------| +| 概览 | 容器数/镜像数/CPU/内存/缓存/存储路径/配置文件 | +| 容器列表 | 启动/停止/删除/查看日志/进入终端 | +| 镜像 | 镜像列表/删除/清理 | +| 应用商店 | ★ 在线模板同步 + 一键部署 | +| 任务中心 | 后台部署任务进度(步骤+日志) | +| 配置 | Docker `daemon.json` 查看/编辑 | +| 网络 | Docker 网络列表/创建/删除 | +| 卷 | Docker 数据卷列表/删除 | + +**Docker 状态管理**: +- 自动检测:`command -v docker` → `docker info` +- 状态机:checking → running / stopped / notInstalled / error +- 启动/停止按钮(停止时先杀容器再 kill dockerd,防止卡死) + +### 5.6 应用商店一键部署 + +这是项目的核心特色功能: + +**数据流**: +``` +MySQL (app_templates + 子表) → Flask API → Electron 主进程 (HTTP GET) + → 本地缓存 (appstore-cache.json) → 前端渲染应用卡片 + → 用户填写参数 → 提交部署 → 后台任务执行 → 实时进度推送 +``` + +**部署步骤**(4 步): +1. **拉取镜像** `docker pull `(超时 5 分钟) +2. **创建目录** `mkdir -p` 宿主机挂载目录 +3. **写入配置** `cat > file << 'EOF'` heredoc 方式写入配置文件 +4. **创建容器** 动态构建 `docker run` 命令并执行 + +**模板参数系统**: +- `{{}}` 占位符替换(如 `{{REDIS_PASSWORD}}`、`{{PORT}}`) +- 特殊占位符 `{{REDIS_PASSWORD_LINE}}`:根据密码是否存在动态生成配置行 +- 条件渲染 `show_when`:如 Halo 选择 MySQL 数据库时才显示 MySQL 配置字段 +- 下拉选项 `options`(JSON 数组):如 Halo 数据库类型选择 H2/MySQL +- 环境变量值含空格时自动加单引号包裹(修复 `-Xmx256m -Xms256m` 的问题) +- `extra_args` 额外参数在镜像名前插入(如 Halo 数据库连接参数) + +**内置模板**: +| 应用 | 镜像 | 说明 | +|------|------|------| +| Redis | `redis:8.10.0` | 密码可选,自定义配置文件 | +| MySQL | `mysql:8.4.11` | root 密码必填,自定义 my.cnf | +| Halo | `registry.fit2cloud.com/halo/halo:2.26` | 支持 H2/MySQL 双模式,JVM 参数可调 | + +### 5.7 防火墙管理 + +- 自动检测防火墙类型:`firewalld` / `ufw` / `iptables` +- firewalld:状态开关、服务/端口管理(添加端口弹窗) +- ufw / iptables:仅展示规则列表 +- 全程复用 `window.panel.exec`,零新增 IPC + +### 5.8 客户端版本管理 + +- 主进程内置 `CLIENT_VERSION` 常量 +- 「更新」按钮触发 `/api/version` 接口检查 +- 弹窗显示三种状态:有新版本(显示更新说明+前往下载)、已是最新、检查失败 +- 下载链接通过 `shell.openExternal` 在系统浏览器打开 + +--- + +## 六、IPC API 参考 + +### 6.1 SSH 连接 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `ssh:connect` | `panel.connect(config)` | 建立 SSH 连接,自动建立 Docker/部署连接 | +| `ssh:disconnect` | `panel.disconnect()` | 断开所有连接 | +| `ssh:session` | `panel.getSession()` | 获取当前会话信息 | +| `ssh:exec` | `panel.exec(cmd, timeout)` | 在主连接上执行命令(串行队列) | +| `ssh:disconnected` | `panel.onDisconnected(cb)` | 监听连接断开事件 | + +### 6.2 凭据管理 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `cred:save` | `panel.saveCredential(cred)` | 保存凭据(密码加密) | +| `cred:list` | `panel.listCredentials()` | 列出已保存凭据 | +| `cred:delete` | `panel.deleteCredential(key)` | 删除凭据 | + +### 6.3 SFTP 文件管理 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `sftp:list` | `panel.sftpList(path)` | 列目录 | +| `sftp:upload` | `panel.sftpUpload(local, remote)` | 上传文件 | +| `sftp:download` | `panel.sftpDownload(remote, local)` | 下载文件 | +| `sftp:mkdir` | `panel.sftpMkdir(path)` | 创建目录 | +| `sftp:delete` | `panel.sftpDelete(path, isDir, recursive)` | 删除文件/目录 | +| `sftp:selectLocalFile` | `panel.sftpSelectLocalFile()` | 选择本地文件(多选) | +| `sftp:selectSavePath` | `panel.sftpSelectSavePath(name)` | 选择保存路径 | +| `sftp:selectDownloadDir` | `panel.sftpSelectDownloadDir()` | 选择下载目录 | +| `sftp:progress` | `panel.onSftpProgress(cb)` | 上传/下载进度事件 | + +### 6.4 交互式终端 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `shell:start` | `panel.shellStart(cols, rows)` | 启动 PTY Shell | +| `shell:write` | `panel.shellWrite(data)` | 向 Shell 写入数据 | +| `shell:resize` | `panel.shellResize(cols, rows)` | 调整终端窗口大小 | +| `shell:close` | `panel.shellClose()` | 关闭 Shell | +| `shell:data` | `panel.onShellData(cb)` | 监听 Shell 输出 | +| `shell:close` | `panel.onShellClose(cb)` | 监听 Shell 关闭 | + +### 6.5 Docker 管理 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `docker:exec` | `panel.dockerExec(cmd, timeout)` | 在 Docker 连接上执行命令 | +| `docker:disconnect` | `panel.dockerDisconnect()` | 断开 Docker 连接 | +| `docker:deployApp` | `panel.dockerDeployApp(deploy, params)` | 提交部署任务 | +| `docker:checkConnections` | `panel.dockerCheckConnections()` | 检查 SSH 连接数 | +| `docker:listTasks` | `panel.dockerListTasks()` | 列出所有部署任务 | +| `deploy:taskUpdate` | `panel.onDeployTaskUpdate(cb)` | 监听部署进度更新 | +| `docker:ready` | `panel.onDockerReady(cb)` | Docker 连接就绪事件 | +| `docker:disconnected` | `panel.onDockerDisconnected(cb)` | Docker 连接断开事件 | + +### 6.6 应用商店与版本管理 + +| IPC 通道 | Preload API | 说明 | +|----------|-------------|------| +| `appstore:sync` | `panel.appstoreSync()` | 从远程 API 同步模板到本地 | +| `appstore:getLocal` | `panel.appstoreGetLocal()` | 获取本地缓存模板 | +| `appstore:getApiUrl` | `panel.appstoreGetApiUrl()` | 获取 API 地址 | +| `appstore:setApiUrl` | `panel.appstoreSetApiUrl(url)` | 设置 API 地址 | +| `appstore:checkInstalled` | `panel.appstoreCheckInstalled()` | 检查已安装应用 | +| `app:checkUpdate` | `panel.appCheckUpdate()` | 检查客户端版本更新 | +| `app:getVersion` | `panel.appGetVersion()` | 获取当前版本号 | +| `app:openUrl` | `panel.appOpenUrl(url)` | 用系统浏览器打开 URL | + +--- + +## 七、应用商店后端服务 + +### 7.1 API 端点 + +| 路由 | 方法 | 说明 | +|------|------|------| +| `/api/apps` | GET | 获取所有应用模板(含完整详情和子表) | +| `/api/apps/` | GET | 获取单个应用模板详情 | +| `/api/version` | GET | 获取客户端最新版本信息 | +| `/api/health` | GET | 健康检查 | + +### 7.2 数据库表结构 + +``` +app_templates (主表) + ├── app_ports (端口映射子表) + ├── app_volumes (挂载卷子表) + ├── app_envs (环境变量子表) + ├── app_mkdirs (自动创建目录子表) + ├── app_write_files (配置文件子表) + └── app_params (用户参数子表,含下拉选项和条件显示) + +app_version (客户端版本管理表) +``` + +### 7.3 部署方式 + +```bash +# 在服务器上执行 +cd appstore-server +bash deploy.sh +# 流程:建表 → 构建镜像 → 启动容器(3001 端口) +``` + +环境变量配置: +- `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`:数据库连接 +- `API_PORT`:API 监听端口(默认 3001) +- `API_TOKEN`:鉴权 token(可选) + +--- + +## 八、开发与构建 + +### 8.1 环境要求 + +- Node.js 18+ +- npm(国内网络建议配置 `.npmrc` 中的 Electron 镜像) +- Python 3.12+(应用商店后端) +- MySQL 8(应用商店数据库) + +### 8.2 开发 + +```bash +npm install # 安装依赖(Electron 自动走国内镜像) +npm run dev # 启动开发服务器(Vite HMR 热更新) +``` + +开发模式下按 `F12` 打开开发者工具。 + +### 8.3 构建 + +```bash +npm run build # 构建产物(输出到 out/) +npm run build:win # 构建 + 打包 Windows 安装程序(输出到 release/) +``` + +### 8.4 关键配置说明 + +**`.npmrc`(Electron 国内镜像)**: +``` +electron_mirror=https://npmmirror.com/mirrors/electron/ +``` + +**`electron-builder` 配置要点**: +- `npmRebuild: false`:跳过 native 模块编译(ssh2 纯 JS 无需编译) +- `target: nsis`:Windows NSIS 安装程序 +- `icon: build/icon.png`:256x256 应用图标 +- 国内打包需配置 `ELECTRON_BUILDER_BINARIES_MIRROR` 镜像 + +--- + +## 九、关键设计决策与踩坑记录 + +### 9.1 SSH Channel 并发问题 + +**问题**:概览页 5 张卡片各自独立轮询,同时打开的 channel 数超出服务器上限(通常 10 个),报错 `Channel open failure: open failed`。 + +**方案**:在主进程 `execSsh` 函数增加串行队列(`execQueue`),所有请求逐个执行,同一时刻只占一个 channel。命令轻量,串行执行几乎无感知延迟。 + +### 9.2 React StrictMode 双挂载 + +**问题**:开发模式下 `React.StrictMode` 会执行两次 `useEffect`(mount→unmount→remount),导致终端首次打开输出重复。 + +**方案**:`shellStart` 放入 `setTimeout(0)` 延迟执行并配合 `disposed` 标记,第一次同步 mount→unmount 时被 cleanup 拦截,仅真正 mount 时连接一次。 + +### 9.3 Electron 二进制下载失败 + +**问题**:`npm install` 时 Electron postinstall 从 GitHub 下载二进制,国内网络经常失败。 + +**方案**:项目根目录创建 `.npmrc` 写入 `electron_mirror=https://npmmirror.com/mirrors/electron/`,安装时自动走国内镜像。 + +### 9.4 Docker 环境变量含空格 + +**问题**:Halo 部署时 `JVM_OPTS=-Xmx256m -Xms256m` 中空格导致 `docker run` 拆分参数报 `unknown shorthand flag`。 + +**方案**:环境变量值含空格时自动加单引号包裹(`parts.push('-e', "'${pair}'")`)。 + +### 9.5 部署连接隔离 + +**问题**:部署任务耗时较长(拉取镜像可能数分钟),如果复用主连接的串行队列会阻塞概览监控等常规操作。 + +**方案**:使用独立的第三条 SSH 连接(`deployConnection`),懒创建,部署任务直接 `exec` 不走串行队列,任务内部 `await` 顺序执行,任务间可并行。 + +--- + +## 十、扩展指南 + +### 10.1 新增应用模板 + +在 `appstore-server/init.sql` 中或通过 API 添加新行到 `app_templates` 主表及对应子表即可,前端无需改代码。`mapDbTemplateToFront` 函数自动将数据库格式转换为前端 deploy 格式。 + +### 10.2 新增功能页面 + +1. 在 `src/renderer/src/pages/` 下新建组件目录 +2. 在 `Panel.jsx` 的 `NAV_ITEMS` 数组添加导航项 +3. 在 `Panel.jsx` 的 `
` 区域添加条件渲染 +4. 如需主进程支持,在 `src/main/index.js` 添加 IPC handler,在 `src/preload/index.js` 暴露 API + +### 10.3 新增 IPC 通道 + +```js +// 主进程 (src/main/index.js) +ipcMain.handle('my:action', async (event, { param }) => { + // 业务逻辑 + return { ok: true, data: result } +}) + +// Preload (src/preload/index.js) +myAction: (param) => ipcRenderer.invoke('my:action', { param }) + +// 渲染进程 +const result = await window.panel.myAction(param) +``` + +> AI生成 \ No newline at end of file diff --git a/appstore-server/init.sql b/appstore-server/init.sql index e3af795..3d90dec 100644 --- a/appstore-server/init.sql +++ b/appstore-server/init.sql @@ -287,4 +287,25 @@ VALUES ('v0.0.1', 'https://114.66.55.63:3001/download/wfj-panel-v0.0.1.exe', - 交互式终端(xterm.js) - Docker 容器管理(概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷) - 应用商店一键部署(Redis/MySQL/Halo) -- 系统防火墙管理(firewalld/ufw/iptables)', 1, NOW()); +- 系统防火墙管理(firewalld/ufw/iptables)', 0, NOW()); + +-- v0.0.2 +INSERT INTO app_version (version, download_url, release_notes, is_latest, created_at) +VALUES ('v0.0.2', 'https://114.66.55.63:3001/download/wfj-panel-v0.0.2.exe', +'WFJ Panel v0.0.2 更新 + +新增功能: +- 应用商店新增 Java 应用模板(支持选择 Java 版本、自定义启动命令、端口映射、路径可视化选择) +- 运行时类容器支持重复部署(自动生成不重复容器名) +- 应用商店参数新增 path 类型(远程目录浏览器选择宿主机路径) + +问题修复: +- 修复任务中心显示占位符 {{CONTAINER_NAME}} 未替换的问题 +- 修复端口映射容器端口占位符未替换导致部署失败的问题 +- 修复 execDeploy 超时后 SSH stream 未销毁导致 channel 泄漏 +- 修复 select 类型参数无默认值时部署按钮误判为必填项未填而置灰 +- docker pull 超时从 5 分钟提升到 10 分钟 + +其他改进: +- 窗口标题由 SSH Panel 改为 WFJ Panel +- 登录页 LOGO 改为 WFJ Panel', 1, NOW()); diff --git a/appstore-server/java_template.sql b/appstore-server/java_template.sql new file mode 100644 index 0000000..7b26554 --- /dev/null +++ b/appstore-server/java_template.sql @@ -0,0 +1,60 @@ +-- ============================================================ +-- Java 应用模板 —— 应用商店热更新(支持选择 Java 版本) +-- 执行方式:在 appstore 数据库中运行此 SQL +-- 执行后在 WFJ Panel 应用商店点击「同步」即可看到新模板 +-- ============================================================ + +-- ─── 扩大端口字段长度,以容纳占位符(如 {{CONTAINER_PORT}} 为 18 字符,原 VARCHAR(16) 不够)─── +ALTER TABLE app_ports MODIFY COLUMN host_port VARCHAR(32) NOT NULL COMMENT '宿主机端口'; +ALTER TABLE app_ports MODIFY COLUMN container_port VARCHAR(32) NOT NULL COMMENT '容器端口'; + +-- ─── 主表 ─── +-- image 使用 {{JAVA_IMAGE}} 占位符,部署时由用户选择的 Java 版本替换 +INSERT INTO app_templates (app_key, name, tagline, description, icon, category, image, container_name, network, restart, hostname, workdir, command, sort_order, is_active) +VALUES ( + 'java', + 'Java 应用', + '运行 Java jar 或 war 文件', + 'Java 镜像默认工作目录为 /app。将主机中包含 jar/war 文件的目录挂载到 /app,通过自定义启动命令运行 Java 应用。支持 JVM 参数、环境变量、Profile 等灵活配置。适合部署 Spring Boot、Quarkus 等打包为 jar 的 Java 服务。', + 'java', + '运行时', + '{{JAVA_IMAGE}}', + '{{CONTAINER_NAME}}', + '', + 'always', + '', + '/app', + '{{START_COMMAND}}', + 4, + 1 +); + +SET @java_id = LAST_INSERT_ID(); + +-- ─── 端口映射:宿主机端口 → 容器端口(Java 应用必须暴露端口才能访问)─── +INSERT INTO app_ports (template_id, host_port, container_port, protocol, sort_order) VALUES + (@java_id, '{{HOST_PORT}}', '{{CONTAINER_PORT}}', 'tcp', 0); + +-- ─── 挂载卷:宿主机应用目录 → /app ─── +INSERT INTO app_volumes (template_id, host_path, container_path, mode, sort_order) VALUES + (@java_id, '{{APP_DIR}}', '/app', 'rw', 0); + +-- ─── 用户参数 ─── +INSERT INTO app_params (template_id, param_key, label, type, placeholder, default_value, required, options, show_when, sort_order) VALUES + -- Java 版本下拉选择,value 即完整镜像地址 + (@java_id, 'JAVA_IMAGE', 'Java 版本', 'select', '', '', 1, + '[{"value":"bitnami/java:latest","label":"Java Latest (最新版)"},{"value":"bitnami/java:21","label":"Java 21 LTS"},{"value":"bitnami/java:17","label":"Java 17 LTS"},{"value":"bitnami/java:11","label":"Java 11 LTS"},{"value":"bitnami/java:8","label":"Java 8 LTS"}]', + NULL, 0), + (@java_id, 'CONTAINER_NAME', '容器名称', 'text', 'java-app', 'java-app', 1, NULL, NULL, 1), + -- 启动命令:完全自定义,支持 JVM 参数、Spring Profile 等 + -- 常见示例: + -- java -jar app.jar + -- java -jar app.jar --spring.profiles.active=prod + -- java -Xmx512m -Xms256m -jar app.jar + -- java -Xmx512m -Dserver.port=8080 -jar app.jar --spring.profiles.active=prod + (@java_id, 'START_COMMAND', '启动命令', 'text', 'java -jar app.jar', 'java -jar app.jar', 1, NULL, NULL, 2), + (@java_id, 'APP_DIR', '宿主机应用目录', 'path', '/path/to/app', '', 1, NULL, NULL, 3), + -- 宿主机端口:外部访问端口,默认 8080 + (@java_id, 'HOST_PORT', '宿主机端口', 'number', '8080', '8080', 1, NULL, NULL, 4), + -- 容器端口:Java 应用监听端口,需与启动命令中 server.port 一致,默认 8080 + (@java_id, 'CONTAINER_PORT', '容器端口', 'number', '8080', '8080', 1, NULL, NULL, 5); diff --git a/package.json b/package.json index 8d1ac4b..59a421d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ssh-panel", - "version": "0.1.0", + "version": "0.0.2", "description": "基于 SSH 的服务器管理面板", "main": "out/main/index.js", "scripts": { diff --git a/src/main/index.js b/src/main/index.js index 2389e99..bdc0ce3 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -260,9 +260,14 @@ function execDeploy(cmd, timeoutMs = 30000) { let settled = false let stdout = '' let stderr = '' + let streamRef = null const timer = setTimeout(() => { if (!settled) { settled = true + // 销毁 stream,防止 channel 泄漏和远程进程继续运行 + if (streamRef) { + try { streamRef.destroy() } catch (_) {} + } resolve({ ok: false, message: '命令执行超时' }) } }, timeoutMs) @@ -276,6 +281,12 @@ function execDeploy(cmd, timeoutMs = 30000) { } return } + streamRef = stream + // 如果已超时,立即销毁新拿到的 stream + if (settled) { + try { stream.destroy() } catch (_) {} + return + } stream.on('data', (chunk) => (stdout += chunk.toString())) stream.stderr.on('data', (chunk) => (stderr += chunk.toString())) stream.on('close', (code) => { @@ -895,11 +906,21 @@ ipcMain.handle('docker:exec', (event, { cmd, timeout }) => execDocker(cmd, timeo // 部署步骤:1.拉取镜像 2.创建目录 3.写入配置 4.创建容器 ipcMain.handle('docker:deployApp', async (event, { deploy, paramValues }) => { + // 参数占位符替换函数(提前定义,用于 appName 等字段) + function tplEarly(str) { + if (!str || typeof str !== 'string') return str + let result = str + for (const [key, val] of Object.entries(paramValues || {})) { + result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val) + } + return result + } + // 创建任务对象 const taskId = ++taskCounter const task = { id: taskId, - appName: deploy.containerName || 'unknown', + appName: tplEarly(deploy.containerName) || deploy.appName || 'unknown', status: 'pending', steps: [], logs: [], @@ -964,8 +985,8 @@ async function runDeployTask(task, deploy, paramValues) { const imageName = tpl(deploy.image) setTaskStep(task, 'pull', `正在拉取镜像 ${imageName}...`) - // 拉取镜像可能耗时较长,超时设为 5 分钟 - const pullResult = await execDeploy(`docker pull ${imageName}`, 300000) + // 拉取镜像可能耗时较长(国内镜像加速也常超 5 分钟),超时设为 10 分钟 + const pullResult = await execDeploy(`docker pull ${imageName}`, 600000) if (!pullResult.ok || (pullResult.code !== 0 && !pullResult.stdout.includes('Status: Image is up to date') && !pullResult.stdout.includes('Already exists'))) { const errMsg = pullResult.stderr || pullResult.message || '未知错误' setTaskStep(task, 'pull-error', `镜像拉取失败: ${errMsg}`) @@ -1027,9 +1048,10 @@ async function runDeployTask(task, deploy, paramValues) { // 端口映射 for (const port of (deploy.ports || [])) { const hostPort = tpl(port.host) - if (!port.container) continue + const containerPort = tpl(port.container) + if (!containerPort) continue let p = hostPort ? `${hostPort}:` : '' - p += port.container + p += containerPort if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol parts.push('-p', p) } diff --git a/src/renderer/index.html b/src/renderer/index.html index 93a12ac..dcce62b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - SSH Panel · 服务器管理面板 + WFJ Panel · 服务器管理面板 ), + java: ( + + + + + + + + ), } return icons[type] || icons.default } +// 远程目录选择器 +function PathPicker({ value, onChange, placeholder }) { + const [open, setOpen] = useState(false) + const [currentPath, setCurrentPath] = useState('/') + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const loadDir = useCallback(async (dir) => { + setLoading(true) + setError('') + try { + const r = await window.panel.sftpList(dir) + if (r.ok) { + setEntries(r.list.filter((e) => e.isDirectory)) + setCurrentPath(dir) + } else { + setError(r.message || '读取目录失败') + setEntries([]) + } + } catch (err) { + setError(err.message || '连接异常') + setEntries([]) + } + setLoading(false) + }, []) + + const openPicker = () => { + // 如果已有值,打开其父目录;否则从根目录开始 + if (value) { + const parts = value.split('/').filter(Boolean) + parts.pop() + const parent = '/' + parts.join('/') + setCurrentPath(parent || '/') + } else { + setCurrentPath('/') + } + setOpen(true) + loadDir(value ? value.replace(/\/[^/]*$/, '') || '/' : '/') + } + + const goToDir = (sub) => { + const next = currentPath === '/' ? '/' + sub : currentPath + '/' + sub + loadDir(next) + } + + const goUp = () => { + if (currentPath === '/') return + const parts = currentPath.split('/').filter(Boolean) + parts.pop() + loadDir('/' + parts.join('/')) + } + + const selectCurrent = () => { + onChange(currentPath) + setOpen(false) + } + + if (open) { + return ( +
setOpen(false)}> +
e.stopPropagation()}> +
+

选择目录

+ +
+
+ + {currentPath} +
+
+ {loading ? ( +
加载中...
+ ) : error ? ( +
{error}
+ ) : entries.length === 0 ? ( +
没有子目录
+ ) : ( + entries.map((e) => ( +
goToDir(e.filename)} + > + + + + {e.filename} +
+ )) + )} +
+
+ + +
+
+
+ ) + } + + return ( +
+ onChange(e.target.value)} + /> + +
+ ) +} + // SSH 连接数警告弹窗 function ConnectionWarning({ count, onConfirm, onCancel }) { return ( @@ -185,11 +323,32 @@ export default function AppStore({ onDeployed, onViewTasks }) { // 判断某个模板是否已安装 const isInstalled = useCallback((template) => { + // 运行时类模板允许重复部署,不显示"已安装"状态 + if (template.category === '运行时') return false const cn = template.deploy?.containerName if (!cn) return false + // containerName 含 {{}} 占位符的也允许重复部署 + if (cn.includes('{{')) return false return installedApps.includes(cn) }, [installedApps]) + // 为运行时类模板自动生成不重复的容器名 + const autoContainerName = useCallback((template) => { + const base = template.deploy?.containerName || '' + // 如果 containerName 不含占位符,直接返回原值 + if (!base.includes('{{')) return base + // 提取占位符外的静态前缀(如 {{CONTAINER_NAME}} → 无前缀) + // 用模板 app_key 作为基础名 + const prefix = template.id || template.deploy?.containerName.replace(/{{.*?}}/g, '') || 'app' + // 找一个不冲突的名称:prefix, prefix-2, prefix-3 ... + let name = prefix + let suffix = 2 + while (installedApps.includes(name)) { + name = `${prefix}-${suffix++}` + } + return name + }, [installedApps]) + // 同步在线商店 const handleSync = useCallback(async () => { setSyncing(true) @@ -221,7 +380,12 @@ export default function AppStore({ onDeployed, onViewTasks }) { if (!selectedApp) return const defaults = {} for (const param of selectedApp.deploy.params || []) { - defaults[param.key] = param.default || '' + if (param.type === 'select' && !param.default && param.options && param.options.length > 0) { + // select 无默认值时自动选中第一个选项,避免状态为空导致按钮置灰 + defaults[param.key] = param.options[0].value + } else { + defaults[param.key] = param.default || '' + } } // Halo + MySQL 联动:如果 MySQL 容器已安装,自动填充默认值 if (selectedApp.id === 'halo' && installedApps.includes('mysql')) { @@ -231,8 +395,17 @@ export default function AppStore({ onDeployed, onViewTasks }) { defaults['DB_NAME'] = 'halodb' defaults['DB_USER'] = 'root' } + // 运行时类模板:自动填充不重复的容器名 + if (selectedApp.category === '运行时') { + const autoName = autoContainerName(selectedApp) + // 找到 CONTAINER_NAME 参数并覆盖默认值 + const hasContainerNameParam = (selectedApp.deploy.params || []).some((p) => p.key === 'CONTAINER_NAME') + if (hasContainerNameParam) { + defaults['CONTAINER_NAME'] = autoName + } + } setParamValues(defaults) - }, [selectedApp, installedApps]) + }, [selectedApp, installedApps, autoContainerName]) // 执行部署(先检查连接数) const handleDeploy = useCallback(async () => { @@ -441,7 +614,7 @@ export default function AppStore({ onDeployed, onViewTasks }) { {/* 参数输入表单 */} - {installed ? ( + {(installed && app.category !== '运行时') ? (
@@ -474,6 +647,12 @@ export default function AppStore({ onDeployed, onViewTasks }) { ))} + ) : param.type === 'path' ? ( + setParamValues((prev) => ({ ...prev, [param.key]: val }))} + placeholder={param.placeholder} + /> ) : (