v0.0.2: Java应用模板、任务中心占位符修复、端口映射修复、WFJ Panel品牌统一

This commit is contained in:
2026-09-08 12:00:49 +08:00
parent 90bc4bd398
commit c98716dd7b
8 changed files with 948 additions and 13 deletions
+491
View File
@@ -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 <token>` 校验
---
## 五、功能模块详解
### 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 <image>`(超时 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/<app_key>` | 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` 的 `<main>` 区域添加条件渲染
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生成
+22 -1
View File
@@ -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());
+60
View File
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ssh-panel",
"version": "0.1.0",
"version": "0.0.2",
"description": "基于 SSH 的服务器管理面板",
"main": "out/main/index.js",
"scripts": {
+27 -5
View File
@@ -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)
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSH Panel · 服务器管理面板</title>
<title>WFJ Panel · 服务器管理面板</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
@@ -80,6 +80,11 @@
color: #6366f1;
}
.app-icon-java {
background: rgba(237, 117, 23, 0.08);
color: #ed7517;
}
/* ---- 应用卡片信息 ---- */
.app-card-info {
flex: 1;
@@ -853,3 +858,160 @@
color: var(--status-critical);
background: rgba(239, 68, 68, 0.06);
}
/* ============================================================
远程目录选择器
============================================================ */
.path-input-wrapper {
display: flex;
gap: 6px;
flex: 1;
}
.path-input-field {
flex: 1;
}
.path-browse-btn {
height: 36px;
padding: 0 10px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.path-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1400;
}
.path-picker-dialog {
background: var(--card-bg);
border-radius: var(--radius-card);
box-shadow: var(--shadow-overlay);
width: 90%;
max-width: 480px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.path-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
.path-picker-header h3 {
font-size: 15px;
font-weight: 600;
color: var(--ink-strong);
margin: 0;
}
.path-picker-close {
border: none;
background: none;
font-size: 22px;
color: var(--ink-muted);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.path-picker-close:hover {
color: var(--ink-strong);
}
.path-picker-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
.path-picker-cwd {
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
color: var(--ink-secondary);
background: var(--surface-dim);
padding: 4px 10px;
border-radius: var(--radius-sm);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
min-height: 200px;
max-height: 320px;
}
.path-picker-loading,
.path-picker-empty,
.path-picker-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--ink-muted);
}
.path-picker-error {
color: var(--status-critical);
}
.path-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
cursor: pointer;
font-size: 13px;
color: var(--ink-strong);
transition: background 0.1s ease;
}
.path-picker-item:hover {
background: var(--brand-soft);
color: var(--brand);
}
.path-picker-item svg {
flex-shrink: 0;
color: var(--ink-muted);
}
.path-picker-item:hover svg {
color: var(--brand);
}
.path-picker-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--hairline);
flex-shrink: 0;
}
+184 -5
View File
@@ -32,10 +32,148 @@ function AppIcon({ type }) {
<path d="M3 9h18M9 21V9" />
</svg>
),
java: (
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M11 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M14 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M6 16h12M6 16v-4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v4" />
<path d="M9 11V7a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4" />
</svg>
),
}
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 (
<div className="path-picker-overlay" onClick={() => setOpen(false)}>
<div className="path-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="path-picker-header">
<h3>选择目录</h3>
<button className="path-picker-close" onClick={() => setOpen(false)}>×</button>
</div>
<div className="path-picker-bar">
<button className="btn btn-ghost btn-sm" onClick={goUp} disabled={currentPath === '/'}>
↑ 上级
</button>
<code className="path-picker-cwd">{currentPath}</code>
</div>
<div className="path-picker-body">
{loading ? (
<div className="path-picker-loading">加载中...</div>
) : error ? (
<div className="path-picker-error">{error}</div>
) : entries.length === 0 ? (
<div className="path-picker-empty">没有子目录</div>
) : (
entries.map((e) => (
<div
key={e.filename}
className="path-picker-item"
onClick={() => goToDir(e.filename)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
</svg>
<span>{e.filename}</span>
</div>
))
)}
</div>
<div className="path-picker-footer">
<button className="btn btn-ghost" onClick={() => setOpen(false)}>取消</button>
<button className="btn btn-primary" onClick={selectCurrent}>
选择此目录
</button>
</div>
</div>
</div>
)
}
return (
<div className="path-input-wrapper">
<input
type="text"
className="param-input path-input-field"
placeholder={placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
className="btn btn-ghost path-browse-btn"
onClick={openPicker}
title="浏览远程目录"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
</svg>
浏览
</button>
</div>
)
}
// 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 }) {
</div>
{/* 参数输入表单 */}
{installed ? (
{(installed && app.category !== '运行时') ? (
<div className="app-installed-notice">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
@@ -474,6 +647,12 @@ export default function AppStore({ onDeployed, onViewTasks }) {
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : param.type === 'path' ? (
<PathPicker
value={paramValues[param.key] || ''}
onChange={(val) => setParamValues((prev) => ({ ...prev, [param.key]: val }))}
placeholder={param.placeholder}
/>
) : (
<input
type={param.type === 'password' ? 'password' : 'text'}
@@ -507,13 +686,13 @@ export default function AppStore({ onDeployed, onViewTasks }) {
<button
className="btn btn-primary"
onClick={handleDeploy}
disabled={installed || deploying || (d.params || []).some((p) => {
disabled={(installed && app.category !== '运行时') || deploying || (d.params || []).some((p) => {
if (!p.required) return false
if (p.showWhen && paramValues[p.showWhen.param] !== p.showWhen.equals) return false
return !paramValues[p.key]
})}
>
{installed ? '已安装' : deploying ? '提交中...' : '一键部署'}
{(installed && app.category !== '运行时') ? '已安装' : deploying ? '提交中...' : '一键部署'}
</button>
</div>
</div>