Files
ssh_panel/src/main/index.js
T
AKAxedx 90bc4bd398 feat: WFJ Panel v0.1.0 - 完整功能版
- SSH 服务器连接管理 + 登录页自动预填
- 服务器概览监控(CPU/内存/硬盘/网络/进程,进程卡片支持排序和端口显示)
- 远程文件管理(浏览/上传/下载/新建/删除,实时进度条)
- 交互式终端(xterm.js + ssh2 PTY)
- Docker 容器管理(三连接架构,概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷)
- 应用商店一键部署(Redis/MySQL/Halo,在线模板同步,数据驱动 options/showWhen/extraArgs)
- Docker 镜像源配置(daemon.json 编辑 + 自动重启)
- Docker 强制停止修复(三步强制停止 + 轮询确认)
- 系统防火墙管理(firewalld/ufw/iptables 自动检测 + GUI 管理)
- 客户端版本检查(/api/version 接口 + 更新弹窗)
- 品牌名统一为 WFJ Panel
- electron-builder 打包配置(NSIS 安装包,国内镜像)
- .npmrc 配置 Electron 国内镜像
2026-09-08 01:25:54 +08:00

1378 lines
41 KiB
JavaScript
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 { app, BrowserWindow, Menu, ipcMain, safeStorage, dialog, shell } from 'electron'
import path from 'path'
import fs from 'fs'
import https from 'https'
import http from 'http'
import { Client } from 'ssh2'
// 隐藏 Electron 默认菜单(File / Edit 等)
Menu.setApplicationMenu(null)
let mainWindow = null
let activeConnection = null // 概览/文件/终端 用的 SSH 连接
let activeSession = null // { host, username },登录成功后记录
let activeSftp = null // 缓存的 SFTP 会话,复用同一个 channel
let dockerConnection = null // Docker 模块专用 SSH 连接(与 activeConnection 隔离)
let dockerSession = null // docker 连接的会话信息
let deployConnection = null // 部署任务专用 SSH 连接(第 3 条连接)
let deploySession = null // 部署连接的凭据 { host, port, username, password }
// ---------- 后台部署任务管理 ----------
// 每个 task: { id, appName, status, steps[], logs[], startTime, error, containerId }
// status: pending | running | done | error
const deployTasks = []
let taskCounter = 0
function sendTaskUpdate(task) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deploy:taskUpdate', {
id: task.id,
appName: task.appName,
status: task.status,
steps: task.steps,
logs: task.logs,
startTime: task.startTime,
error: task.error,
containerId: task.containerId,
})
}
}
function addTaskLog(task, message) {
task.logs.push({
time: new Date().toLocaleTimeString(),
message,
})
sendTaskUpdate(task)
}
function setTaskStep(task, step, message) {
// 更新步骤状态
const stepMap = {
pull: '拉取镜像',
mkdir: '创建目录',
writefile: '写入配置',
run: '创建容器',
done: '完成',
}
const stepLabel = stepMap[step.replace('-done', '').replace('-error', '')] || step
const status = step.includes('error') ? 'error' : step.includes('done') || step === 'done' ? 'done' : 'running'
// 找到或创建步骤
let stepObj = task.steps.find((s) => s.name === stepLabel)
if (!stepObj) {
stepObj = { name: stepLabel, status: 'pending' }
task.steps.push(stepObj)
}
stepObj.status = status
if (message) addTaskLog(task, message)
else sendTaskUpdate(task)
}
// ---------- 凭据存储 ----------
const credFile = () => path.join(app.getPath('userData'), 'credentials.json')
function readCredentials() {
try {
return JSON.parse(fs.readFileSync(credFile(), 'utf8'))
} catch {
return []
}
}
function writeCredentials(list) {
fs.mkdirSync(path.dirname(credFile()), { recursive: true })
fs.writeFileSync(credFile(), JSON.stringify(list, null, 2), 'utf8')
}
// 密码优先用系统安全存储加密(Windows DPAPI),不可用时降级为 base64
function encryptPassword(password) {
if (safeStorage.isEncryptionAvailable()) {
return { data: safeStorage.encryptString(password).toString('base64'), encrypted: true }
}
return { data: Buffer.from(password, 'utf8').toString('base64'), encrypted: false }
}
function decryptPassword(entry) {
if (entry.encrypted) {
return safeStorage.decryptString(Buffer.from(entry.data, 'base64'))
}
return Buffer.from(entry.data, 'base64').toString('utf8')
}
// ---------- 窗口 ----------
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
backgroundColor: '#eef1f6',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},
})
if (process.env['ELECTRON_RENDERER_URL']) {
// 开发模式:加载 Vite 开发服务器(支持热更新)
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
// 菜单已隐藏,用 F12 开关开发者工具
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') mainWindow.webContents.toggleDevTools()
})
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
if (activeShell) {
activeShell.end()
activeShell = null
}
activeSftp = null
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
if (dockerConnection) {
dockerConnection.end()
dockerConnection = null
}
if (deployConnection) {
deployConnection.end()
deployConnection = null
}
})
}
// ---------- IPC:SSH 连接 ----------
// 内部函数:建立 Docker 专用 SSH 连接
function connectDocker(host, port, username, password) {
if (dockerConnection) {
dockerConnection.end()
dockerConnection = null
}
const client = new Client()
client.on('ready', () => {
dockerConnection = client
dockerSession = { host, username }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('docker:ready', { ok: true })
}
})
client.on('error', () => {
if (dockerConnection === client) {
dockerConnection = null
dockerSession = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('docker:disconnected', { message: 'Docker SSH 连接异常' })
}
}
})
client.on('close', () => {
if (dockerConnection === client) {
dockerConnection = null
dockerSession = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('docker:disconnected', { message: 'Docker SSH 连接已断开' })
}
}
})
client.connect({
host,
port: Number(port),
username,
password,
readyTimeout: 10000,
})
}
// 内部函数:获取或创建部署专用 SSH 连接
function getDeployConnection() {
return new Promise((resolve, reject) => {
// 已有连接且仍然存活
if (deployConnection) {
resolve(deployConnection)
return
}
if (!deploySession) {
reject(new Error('部署连接凭据不存在'))
return
}
const { host, port, username, password } = deploySession
const client = new Client()
let settled = false
client.on('ready', () => {
if (settled) return
settled = true
deployConnection = client
resolve(client)
})
client.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
}
if (deployConnection === client) {
deployConnection = null
}
})
client.on('close', () => {
if (!settled) {
settled = true
reject(new Error('部署连接被关闭'))
}
if (deployConnection === client) {
deployConnection = null
}
})
client.connect({
host,
port: Number(port),
username,
password,
readyTimeout: 10000,
})
})
}
// 在部署连接上执行命令(直接并发,不加串行队列)
// 每个部署任务内部是 await 顺序执行的,多个任务之间可以并行
// SSH 单连接支持多个并发 channel,2-3 个部署任务完全够用
function execDeploy(cmd, timeoutMs = 30000) {
return new Promise((resolve) => {
getDeployConnection().then((conn) => {
let settled = false
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
if (!settled) {
settled = true
resolve({ ok: false, message: '命令执行超时' })
}
}, timeoutMs)
conn.exec(cmd, (err, stream) => {
if (err) {
clearTimeout(timer)
if (!settled) {
settled = true
resolve({ ok: false, message: err.message })
}
return
}
stream.on('data', (chunk) => (stdout += chunk.toString()))
stream.stderr.on('data', (chunk) => (stderr += chunk.toString()))
stream.on('close', (code) => {
clearTimeout(timer)
if (!settled) {
settled = true
resolve({ ok: true, code, stdout, stderr })
}
})
})
}).catch((err) => {
resolve({ ok: false, message: err.message })
})
})
}
ipcMain.handle('ssh:connect', (event, { host, port, username, password }) => {
return new Promise((resolve) => {
// 已有连接时先断开旧连接
if (activeShell) {
activeShell.end()
activeShell = null
}
if (activeSftp) {
activeSftp = null
}
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
const client = new Client()
let settled = false
client.on('ready', () => {
if (settled) return
settled = true
activeConnection = client
activeSession = { host, username }
// 保存部署连接凭据(懒创建,首次部署时才建立连接)
deploySession = { host, port: Number(port), username, password }
// 自动建立 Docker 专用连接(复用相同凭据)
connectDocker(host, port, username, password)
resolve({ ok: true })
})
// 连接阶段的错误 → resolve 给前端
// 连接成功后的运行期错误 → 仅清理连接状态,不 crash
client.on('error', (err) => {
if (!settled) {
settled = true
resolve({ ok: false, message: err.message })
}
// 运行期 error:清理连接,让 UI 提示断开重连
if (activeConnection === client) {
activeConnection = null
activeSftp = null
activeSession = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('ssh:disconnected', { message: err.message })
}
}
})
client.on('close', () => {
if (!settled) {
settled = true
resolve({ ok: false, message: '连接被关闭' })
}
if (activeConnection === client) {
activeConnection = null
activeSftp = null
activeSession = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('ssh:disconnected', { message: '连接已断开' })
}
}
})
client.connect({
host,
port: Number(port),
username,
password,
readyTimeout: 10000,
})
})
})
ipcMain.handle('ssh:disconnect', () => {
if (activeShell) {
activeShell.end()
activeShell = null
}
activeSftp = null
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
activeSession = null
// 同时断开 Docker 专用连接
if (dockerConnection) {
dockerConnection.end()
dockerConnection = null
}
dockerSession = null
// 断开部署专用连接
if (deployConnection) {
deployConnection.end()
deployConnection = null
}
deploySession = null
return { ok: true }
})
ipcMain.handle('ssh:session', () => activeSession)
// 在活动的 SSH 连接上执行命令
// 使用队列串行化所有 exec 请求,避免并发打开过多 SSH channel 导致
// "Channel open failure: open failed" 错误
const execQueue = []
let execRunning = false
function execSsh(cmd, timeoutMs = 10000) {
return new Promise((resolve) => {
execQueue.push({ cmd, timeoutMs, resolve })
drainExecQueue()
})
}
function drainExecQueue() {
if (execRunning || execQueue.length === 0) return
execRunning = true
const item = execQueue.shift()
if (item.task) {
// SFTP 任务
item
.task()
.then((result) => item.resolve(result))
.catch((err) => item.resolve({ ok: false, message: err.message }))
.finally(() => {
execRunning = false
drainExecQueue()
})
} else {
// exec 任务
runExec(item.cmd, item.timeoutMs, (result) => {
item.resolve(result)
execRunning = false
// 继续处理队列中下一个请求
drainExecQueue()
})
}
}
function runExec(cmd, timeoutMs, done) {
if (!activeConnection) {
done({ ok: false, message: 'SSH 连接不存在' })
return
}
let settled = false
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
if (!settled) {
settled = true
done({ ok: false, message: '命令执行超时' })
}
}, timeoutMs)
activeConnection.exec(cmd, (err, stream) => {
if (err) {
clearTimeout(timer)
if (!settled) {
settled = true
done({ ok: false, message: err.message })
}
return
}
stream.on('data', (chunk) => (stdout += chunk.toString()))
stream.stderr.on('data', (chunk) => (stderr += chunk.toString()))
stream.on('close', (code) => {
clearTimeout(timer)
if (!settled) {
settled = true
done({ ok: true, code, stdout, stderr })
}
})
})
}
ipcMain.handle('ssh:exec', (event, { cmd, timeout }) => execSsh(cmd, timeout))
// ---------- IPC:凭据 ----------
ipcMain.handle('cred:save', (event, { host, port, username, password }) => {
const list = readCredentials()
const entry = { host, port: Number(port), username, password: encryptPassword(password) }
// 同一 host + username 覆盖旧记录
const idx = list.findIndex((c) => c.host === host && c.username === username)
if (idx >= 0) list[idx] = entry
else list.push(entry)
writeCredentials(list)
return { ok: true }
})
ipcMain.handle('cred:list', () => {
return readCredentials()
.map((c) => {
try {
return {
host: c.host,
port: c.port,
username: c.username,
password: decryptPassword(c.password),
}
} catch {
return null // 本机无法解密(比如从别的机器拷贝来的),跳过
}
})
.filter(Boolean)
})
ipcMain.handle('cred:delete', (event, { host, username }) => {
writeCredentials(readCredentials().filter((c) => !(c.host === host && c.username === username)))
return { ok: true }
})
// ---------- IPC:SFTP 文件管理 ----------
// SFTP 操作也走串行队列,和 exec 共用同一个队列,
// 避免并发打开过多 SSH channel 导致 "Channel open failure"
function runSftpTask(task) {
return new Promise((resolve) => {
execQueue.push({
resolve,
task, // async function() => result
})
drainExecQueue()
})
}
// 获取 SFTP 会话(复用 activeConnection,缓存同一个 SFTP channel)
function getSftp() {
return new Promise((resolve, reject) => {
if (!activeConnection) {
reject(new Error('SSH 连接不存在'))
return
}
// 已有缓存的 sftp 会话且连接仍然有效,直接复用
if (activeSftp) {
resolve(activeSftp)
return
}
activeConnection.sftp((err, sftp) => {
if (err) reject(err)
else {
activeSftp = sftp
resolve(sftp)
}
})
})
}
// 列目录
ipcMain.handle('sftp:list', async (event, { remotePath }) => {
return runSftpTask(async () => {
const sftp = await getSftp()
const list = await new Promise((resolve, reject) => {
sftp.readdir(remotePath, (err, items) => {
if (err) reject(err)
else resolve(items)
})
})
// 规范化字段
const result = list.map((item) => ({
filename: item.filename,
longname: item.longname || '',
attrs: {
size: item.attrs.size,
mtime: item.attrs.mtime,
atime: item.attrs.atime,
permissions: item.attrs.permissions,
uid: item.attrs.uid,
gid: item.attrs.gid,
},
isDirectory: item.attrs.isDirectory(),
isFile: item.attrs.isFile(),
isSymlink: item.attrs.isSymbolicLink(),
}))
// 目录排前,文件排后,同类按名称排序
result.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
return a.filename.localeCompare(b.filename)
})
return { ok: true, list: result }
})
})
// 上传文件(本地 → 远程)
ipcMain.handle('sftp:upload', async (event, { localPath, remotePath }) => {
return runSftpTask(async () => {
const sftp = await getSftp()
const totalSize = fs.statSync(localPath).size
let uploaded = 0
return await new Promise((resolve) => {
const rs = fs.createReadStream(localPath)
const ws = sftp.createWriteStream(remotePath)
rs.on('data', (chunk) => {
uploaded += chunk.length
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('sftp:progress', {
action: 'upload',
filename: path.basename(localPath),
transferred: uploaded,
total: totalSize,
})
}
})
ws.on('close', () => {
resolve({ ok: true, transferred: uploaded, total: totalSize })
})
ws.on('error', (err) => {
resolve({ ok: false, message: err.message })
})
rs.on('error', (err) => {
resolve({ ok: false, message: err.message })
})
rs.pipe(ws)
})
})
})
// 下载文件(远程 → 本地)
ipcMain.handle('sftp:download', async (event, { remotePath, localPath }) => {
return runSftpTask(async () => {
const sftp = await getSftp()
const stat = await new Promise((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
if (err) reject(err)
else resolve(stats)
})
})
const totalSize = stat.size
let downloaded = 0
return await new Promise((resolve) => {
const rs = sftp.createReadStream(remotePath)
const ws = fs.createWriteStream(localPath)
rs.on('data', (chunk) => {
downloaded += chunk.length
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('sftp:progress', {
action: 'download',
filename: path.basename(remotePath),
transferred: downloaded,
total: totalSize,
})
}
})
ws.on('close', () => {
resolve({ ok: true, transferred: downloaded, total: totalSize })
})
ws.on('error', (err) => {
resolve({ ok: false, message: err.message })
})
rs.on('error', (err) => {
resolve({ ok: false, message: err.message })
})
rs.pipe(ws)
})
})
})
// 打开本地文件选择对话框(用于上传,支持多选)
ipcMain.handle('sftp:selectLocalFile', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
title: '选择要上传的文件',
properties: ['openFile', 'multiSelections'],
})
if (result.canceled || result.filePaths.length === 0) return { ok: false }
return { ok: true, paths: result.filePaths }
})
// 打开本地保存对话框(用于下载单个文件)
ipcMain.handle('sftp:selectSavePath', async (event, { defaultName }) => {
const result = await dialog.showSaveDialog(mainWindow, {
title: '保存文件',
defaultPath: defaultName || 'download',
})
if (result.canceled) return { ok: false }
return { ok: true, path: result.filePath }
})
// 打开本地目录选择对话框(用于批量下载到同一目录)
ipcMain.handle('sftp:selectDownloadDir', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
title: '选择下载保存目录',
properties: ['openDirectory'],
})
if (result.canceled || result.filePaths.length === 0) return { ok: false }
return { ok: true, path: result.filePaths[0] }
})
// 创建远程目录
ipcMain.handle('sftp:mkdir', async (event, { remotePath }) => {
return runSftpTask(async () => {
const sftp = await getSftp()
await new Promise((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) reject(err)
else resolve()
})
})
return { ok: true }
})
})
// 删除远程文件或目录
// 统一用 exec rm -rf 命令删除,不走 SFTP,避免 channel 问题
ipcMain.handle('sftp:delete', async (event, { remotePath, isDirectory, recursive }) => {
// 路径用单引号包裹防止特殊字符注入
const safePath = "'" + remotePath.replace(/'/g, "'\\''") + "'"
// rm -rf 对目录递归删除,对文件直接删除
const result = await execSsh('rm -rf ' + safePath, 30000)
if (!result.ok) return { ok: false, message: result.message || result.stderr || '删除失败' }
if (result.code !== 0) return { ok: false, message: result.stderr || '删除失败' }
return { ok: true }
})
// ---------- IPC:交互式终端 (Shell with PTY) ----------
let activeShell = null // 当前活动的 SSH Shell 流
// 启动交互式 Shell
ipcMain.handle('shell:start', async (event, { cols, rows }) => {
try {
if (!activeConnection) {
return { ok: false, message: 'SSH 连接不存在' }
}
// 如果已有 Shell 在运行,先关闭
if (activeShell) {
activeShell.end()
activeShell = null
}
const shell = await new Promise((resolve, reject) => {
activeConnection.shell(
{
cols: cols || 80,
rows: rows || 24,
term: 'xterm-256color',
},
(err, stream) => {
if (err) reject(err)
else resolve(stream)
}
)
})
activeShell = shell
// 接收服务器输出 → 转发给渲染进程
shell.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shell:data', data)
}
})
shell.stderr.on('data', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shell:data', data)
}
})
shell.on('close', () => {
activeShell = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shell:close')
}
})
return { ok: true }
} catch (err) {
return { ok: false, message: err.message }
}
})
// 渲染进程向 Shell 写入数据(键盘输入)
ipcMain.handle('shell:write', async (event, { data }) => {
if (activeShell && !activeShell.destroyed) {
activeShell.write(data)
return { ok: true }
}
return { ok: false, message: 'Shell 未连接' }
})
// 调整终端窗口大小
ipcMain.handle('shell:resize', async (event, { cols, rows }) => {
if (activeShell && !activeShell.destroyed) {
activeShell.setWindow(rows, cols, 0, 0)
return { ok: true }
}
return { ok: false, message: 'Shell 未连接' }
})
// 关闭 Shell
ipcMain.handle('shell:close', async () => {
if (activeShell) {
activeShell.end()
activeShell = null
}
return { ok: true }
})
// ---------- IPC:Docker 专用 SSH 连接 ----------
// Docker 连接在 ssh:connect 成功后自动建立,前端无需手动连接
// 这里只提供 disconnect 和 exec
ipcMain.handle('docker:disconnect', () => {
if (dockerConnection) {
dockerConnection.end()
dockerConnection = null
}
dockerSession = null
return { ok: true }
})
// Docker 专用串行队列(与主 execQueue 隔离)
const dockerQueue = []
let dockerRunning = false
function execDocker(cmd, timeoutMs = 15000) {
return new Promise((resolve) => {
dockerQueue.push({ cmd, timeoutMs, resolve })
drainDockerQueue()
})
}
function drainDockerQueue() {
if (dockerRunning || dockerQueue.length === 0) return
dockerRunning = true
const { cmd, timeoutMs, resolve } = dockerQueue.shift()
runDockerExec(cmd, timeoutMs, (result) => {
resolve(result)
dockerRunning = false
drainDockerQueue()
})
}
function runDockerExec(cmd, timeoutMs, done) {
if (!dockerConnection) {
done({ ok: false, message: 'Docker SSH 连接不存在' })
return
}
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 (_) {}
}
done({ ok: false, message: '命令执行超时' })
}
}, timeoutMs)
dockerConnection.exec(cmd, (err, stream) => {
if (err) {
clearTimeout(timer)
if (!settled) {
settled = true
done({ ok: false, message: err.message })
}
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) => {
clearTimeout(timer)
if (!settled) {
settled = true
done({ ok: true, code, stdout, stderr })
}
})
})
}
ipcMain.handle('docker:exec', (event, { cmd, timeout }) => execDocker(cmd, timeout))
// ---------- IPC:应用商店一键部署(后台任务模式) ----------
// 使用 deployConnection(第 3 条 SSH 连接)在后台执行部署
// 前端通过 docker:listTasks / deploy:taskUpdate 获取进度
// 部署步骤:1.拉取镜像 2.创建目录 3.写入配置 4.创建容器
ipcMain.handle('docker:deployApp', async (event, { deploy, paramValues }) => {
// 创建任务对象
const taskId = ++taskCounter
const task = {
id: taskId,
appName: deploy.containerName || 'unknown',
status: 'pending',
steps: [],
logs: [],
startTime: new Date().toISOString(),
error: null,
containerId: null,
}
deployTasks.push(task)
// 后台执行部署(不 await,立即返回 taskId)
runDeployTask(task, deploy, paramValues || {})
return { ok: true, taskId }
})
// 后台部署任务执行函数
async function runDeployTask(task, deploy, paramValues) {
// ---- 参数占位符替换 ----
function tpl(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
}
// 特殊占位符处理
function tplWithDerived(str) {
if (!str || typeof str !== 'string') return str
let result = tpl(str)
// Redis 密码行
const redisPwd = paramValues['REDIS_PASSWORD'] || ''
result = result.replace(/\{\{REDIS_PASSWORD_LINE\}\}/g, redisPwd ? `requirepass ${redisPwd}` : '# requirepass (未设置密码)')
// Halo 数据库参数:H2 模式为空,MySQL 模式拼装环境变量
const dbType = paramValues['DB_TYPE'] || 'h2'
if (dbType === 'mysql') {
const dbHost = paramValues['DB_HOST'] || '127.0.0.1'
const dbPort = paramValues['DB_PORT'] || '3306'
const dbName = paramValues['DB_NAME'] || 'halodb'
const dbUser = paramValues['DB_USER'] || 'root'
const dbPwd = paramValues['DB_PASSWORD'] || ''
const r2dbcUrl = `r2dbc:pool:mysql://${dbHost}:${dbPort}/${dbName}`
const dbArgs = [
`-e spring.r2dbc.url=${r2dbcUrl}`,
`-e spring.r2dbc.username=${dbUser}`,
`-e spring.r2dbc.password=${dbPwd}`,
`-e spring.sql.init.platform=mysql`,
].join(' ')
result = result.replace(/\{\{HALO_DB_ARGS\}\}/g, dbArgs)
} else {
result = result.replace(/\{\{HALO_DB_ARGS\}\}/g, '')
}
return result
}
task.status = 'running'
sendTaskUpdate(task)
try {
// ---- Step 1: 拉取镜像 ----
const imageName = tpl(deploy.image)
setTaskStep(task, 'pull', `正在拉取镜像 ${imageName}...`)
// 拉取镜像可能耗时较长,超时设为 5 分钟
const pullResult = await execDeploy(`docker pull ${imageName}`, 300000)
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}`)
task.status = 'error'
task.error = `镜像拉取失败: ${errMsg}`
sendTaskUpdate(task)
return
}
setTaskStep(task, 'pull-done', `镜像 ${imageName} 准备就绪`)
// ---- Step 2: 创建宿主机目录 ----
if (deploy.mkdirs && deploy.mkdirs.length > 0) {
for (const dir of deploy.mkdirs) {
const dirPath = tpl(dir)
setTaskStep(task, 'mkdir', `创建目录 ${dirPath}`)
const mkdirResult = await execDeploy(`mkdir -p '${dirPath}'`, 10000)
if (!mkdirResult.ok || mkdirResult.code !== 0) {
setTaskStep(task, 'mkdir-error', `创建目录失败: ${dirPath}`)
task.status = 'error'
task.error = `创建目录失败: ${dirPath}`
sendTaskUpdate(task)
return
}
}
setTaskStep(task, 'mkdir-done', '目录创建完成')
}
// ---- Step 3: 写入配置文件 ----
if (deploy.writeFiles && deploy.writeFiles.length > 0) {
for (const file of deploy.writeFiles) {
const filePath = tpl(file.path)
const content = tplWithDerived(file.content)
setTaskStep(task, 'writefile', `写入配置文件 ${filePath}`)
// 用 heredoc 方式写入文件,避免转义问题
const writeCmd = `cat > '${filePath}' << 'APPSTORE_EOF'\n${content}\nAPPSTORE_EOF`
const writeResult = await execDeploy(writeCmd, 10000)
if (!writeResult.ok || writeResult.code !== 0) {
setTaskStep(task, 'writefile-error', `写入配置文件失败: ${filePath}`)
task.status = 'error'
task.error = `写入配置文件失败: ${filePath}`
sendTaskUpdate(task)
return
}
}
setTaskStep(task, 'writefile-done', '配置文件写入完成')
}
// ---- Step 4: 构建 docker run 命令 ----
const parts = ['docker', 'run', '-d']
const containerName = tpl(deploy.containerName)
if (containerName) parts.push('--name', containerName)
if (deploy.restart) parts.push('--restart', deploy.restart)
if (deploy.hostname) parts.push('--hostname', deploy.hostname)
if (deploy.workdir) parts.push('-w', deploy.workdir)
if (deploy.network) parts.push('--network', deploy.network)
// 端口映射
for (const port of (deploy.ports || [])) {
const hostPort = tpl(port.host)
if (!port.container) continue
let p = hostPort ? `${hostPort}:` : ''
p += port.container
if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol
parts.push('-p', p)
}
// 挂载卷
for (const vol of (deploy.volumes || [])) {
if (!vol.container) continue
const hostPath = tpl(vol.host)
let v = hostPath ? `${hostPath}:` : ''
v += vol.container
if (vol.mode && vol.mode !== 'rw') v += ':' + vol.mode
parts.push('-v', v)
}
// 环境变量(值含空格时用引号包裹,防止 docker 解析错误)
for (const env of (deploy.env || [])) {
if (!env.key) continue
const envValue = tpl(env.value)
const pair = `${env.key}=${envValue}`
if (envValue.includes(' ')) {
parts.push('-e', `'${pair}'`)
} else {
parts.push('-e', pair)
}
}
// 额外参数(如 Halo 数据库配置,在镜像名之前插入)
// extraArgs 中的值(DB URL、用户名、密码等)不含空格,可直接 split
if (deploy.extraArgs) {
const extraArgs = tplWithDerived(deploy.extraArgs)
if (extraArgs && extraArgs.trim()) {
parts.push(...extraArgs.trim().split(/\s+/).filter(Boolean))
}
}
// 启动命令
const command = tpl(deploy.command)
parts.push(imageName)
if (command) {
parts.push(...command.split(/\s+/).filter(Boolean))
}
const runCmd = parts.join(' ')
setTaskStep(task, 'run', `正在创建容器...`)
addTaskLog(task, `执行命令: ${runCmd}`)
const runResult = await execDeploy(runCmd, 60000)
if (!runResult.ok || runResult.code !== 0) {
const errMsg = runResult.stderr || runResult.message || '创建容器失败'
setTaskStep(task, 'run-error', `容器创建失败: ${errMsg}`)
task.status = 'error'
task.error = `容器创建失败: ${errMsg}`
sendTaskUpdate(task)
return
}
const containerId = (runResult.stdout || '').trim().split('\n')[0]
task.containerId = containerId
setTaskStep(task, 'run-done', `容器创建成功!ID: ${containerId.slice(0, 12)}`)
setTaskStep(task, 'done', '部署完成')
task.status = 'done'
sendTaskUpdate(task)
} catch (err) {
setTaskStep(task, 'run-error', `部署异常: ${err.message}`)
task.status = 'error'
task.error = `部署异常: ${err.message}`
sendTaskUpdate(task)
}
}
// ---------- IPC:检查 SSH 连接数 ----------
// 检查服务器当前的 SSH 连接数,用于部署前警告
ipcMain.handle('docker:checkConnections', async () => {
// 用 docker 连接执行命令检查当前 SSH 连接数
const r = await execDocker("ss -tn state established '( sport = :22 )' 2>/dev/null | tail -n +2 | wc -l || netstat -tn 2>/dev/null | grep ':22 ' | grep ESTABLISHED | wc -l", 10000)
if (!r.ok) {
return { ok: false, message: r.message || '检查失败' }
}
const count = parseInt((r.stdout || '').trim()) || 0
return { ok: true, count }
})
// ---------- IPC:列出所有部署任务 ----------
ipcMain.handle('docker:listTasks', async () => {
return { ok: true, tasks: deployTasks.map((t) => ({
id: t.id,
appName: t.appName,
status: t.status,
steps: t.steps,
logs: t.logs,
startTime: t.startTime,
error: t.error,
containerId: t.containerId,
}))}
})
// ---------- 在线应用商店 ----------
// API 地址持久化在 Electron userData 目录
const appstoreConfigPath = path.join(app.getPath('userData'), 'appstore-config.json')
// 在线模板缓存路径
const appstoreCachePath = path.join(app.getPath('userData'), 'appstore-cache.json')
// 默认 API 地址(可被用户覆盖)
let appstoreApiUrl = 'http://114.66.55.63:3001/api/apps'
// API 鉴权 token
let appstoreApiToken = 'akaxedx'
// 从配置文件加载 API 地址和 token
try {
const configRaw = fs.readFileSync(appstoreConfigPath, 'utf-8')
const config = JSON.parse(configRaw)
if (config.apiUrl) appstoreApiUrl = config.apiUrl
if (config.apiToken !== undefined) appstoreApiToken = config.apiToken
} catch (_) {
// 首次运行,使用默认值
}
/**
* 发起 HTTP GET 请求(支持 http / https),附带 Authorization token
*/
function httpGet(urlStr, timeoutMs = 10000) {
return new Promise((resolve) => {
const lib = urlStr.startsWith('https') ? https : http
const headers = {}
if (appstoreApiToken) {
headers['Authorization'] = `Bearer ${appstoreApiToken}`
}
const req = lib.get(urlStr, { timeout: timeoutMs, headers }, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
resolve({ ok: res.statusCode === 200, statusCode: res.statusCode, body: data })
})
})
req.on('error', (err) => {
resolve({ ok: false, error: err.message })
})
req.on('timeout', () => {
req.destroy()
resolve({ ok: false, error: '请求超时' })
})
})
}
// ---------- IPC:获取/设置 API 地址 ----------
ipcMain.handle('appstore:getApiUrl', async () => {
return { ok: true, apiUrl: appstoreApiUrl }
})
ipcMain.handle('appstore:setApiUrl', async (_e, url) => {
appstoreApiUrl = url
try {
fs.writeFileSync(appstoreConfigPath, JSON.stringify({ apiUrl: url }), 'utf-8')
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
})
// ---------- IPC:同步在线模板 ----------
// 从远程 API 获取模板列表,写入本地缓存文件
ipcMain.handle('appstore:sync', async () => {
const res = await httpGet(appstoreApiUrl, 15000)
if (!res.ok) {
return { ok: false, error: res.error || `HTTP ${res.statusCode}` }
}
try {
const parsed = JSON.parse(res.body)
if (!parsed.ok || !Array.isArray(parsed.data)) {
return { ok: false, error: parsed.error || '返回数据格式错误' }
}
// 转换数据库格式 → 前端 deploy 格式
const templates = parsed.data.map(mapDbTemplateToFront)
// 写入缓存
fs.writeFileSync(appstoreCachePath, JSON.stringify({
templates,
syncedAt: new Date().toISOString(),
}), 'utf-8')
return { ok: true, templates, syncedAt: new Date().toISOString() }
} catch (err) {
return { ok: false, error: 'JSON 解析失败: ' + err.message }
}
})
// ---------- IPC:获取本地缓存模板 ----------
ipcMain.handle('appstore:getLocal', async () => {
try {
const raw = fs.readFileSync(appstoreCachePath, 'utf-8')
const cache = JSON.parse(raw)
return { ok: true, templates: cache.templates || [], syncedAt: cache.syncedAt || null }
} catch (_) {
return { ok: true, templates: [], syncedAt: null }
}
})
// ---------- IPC:检查客户端版本更新 ----------
// 客户端当前版本号
const CLIENT_VERSION = 'v0.0.1'
ipcMain.handle('app:checkUpdate', async () => {
// 版本检查接口地址(固定,基于 appstore API 同一服务器)
const versionApiUrl = appstoreApiUrl.replace('/api/apps', '/api/version')
const res = await httpGet(versionApiUrl, 10000)
if (!res.ok) {
return { ok: false, error: res.error || `HTTP ${res.statusCode}` }
}
try {
const parsed = JSON.parse(res.body)
if (!parsed.ok || !parsed.data) {
return { ok: false, error: parsed.error || '返回数据格式错误' }
}
const latest = parsed.data
const hasUpdate = latest.version !== CLIENT_VERSION
return {
ok: true,
currentVersion: CLIENT_VERSION,
latestVersion: latest.version,
downloadUrl: latest.downloadUrl,
releaseNotes: latest.releaseNotes || '',
hasUpdate,
}
} catch (err) {
return { ok: false, error: 'JSON 解析失败: ' + err.message }
}
})
// ---------- IPC:获取当前版本号 ----------
ipcMain.handle('app:getVersion', async () => {
return { ok: true, version: CLIENT_VERSION }
})
// ---------- IPC:打开下载地址(系统默认浏览器) ----------
ipcMain.handle('app:openUrl', async (_e, url) => {
try {
shell.openExternal(url)
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
})
// ---------- IPC:检查已安装应用 ----------
// 通过 docker 连接执行 docker ps -a 获取已有容器列表
// 返回 { ok, installed: ['redis', 'mysql', ...] }
ipcMain.handle('appstore:checkInstalled', async () => {
if (!dockerConnection) {
return { ok: false, error: 'Docker 未连接' }
}
const r = await execDocker('docker ps -a --format "{{.Names}}" 2>/dev/null', 10000)
if (!r.ok) {
return { ok: false, error: r.message || '检查失败' }
}
const names = (r.stdout || '').trim().split('\n').map((s) => s.trim()).filter(Boolean)
return { ok: true, installed: names }
})
/**
* 将数据库行转换为前端 deploy 格式
* 数据库字段名 → 前端字段名映射
*/
function mapDbTemplateToFront(row) {
const tpl = {
id: row.app_key,
name: row.name,
icon: row.icon || '',
tagline: row.tagline || '',
image: row.image,
description: row.description || '',
category: row.category || '',
deploy: {
image: row.image,
containerName: row.container_name,
ports: (row.ports || []).map((p) => ({
host: p.host_port,
container: p.container_port,
protocol: p.protocol || 'tcp',
})),
volumes: (row.volumes || []).map((v) => ({
host: v.host_path,
container: v.container_path,
mode: v.mode || 'rw',
})),
env: (row.env || []).map((e) => ({
key: e.env_key,
value: e.env_value || '',
})),
network: row.network || '',
restart: row.restart || 'always',
hostname: row.hostname || '',
workdir: row.workdir || '',
command: row.command || '',
mkdirs: (row.mkdirs || []).map((m) => m.dir_path),
writeFiles: (row.writeFiles || []).map((f) => ({
path: f.file_path,
content: f.file_content,
})),
params: (row.params || []).map((p) => {
const param = {
key: p.param_key,
label: p.label,
type: p.type || 'text',
placeholder: p.placeholder || '',
default: p.default_value || '',
required: !!p.required,
}
// select 类型:从 DB options 列读取下拉选项 (JSON 字符串)
if (param.type === 'select' && p.options) {
try {
param.options = JSON.parse(p.options)
} catch {
param.options = []
}
}
// 条件渲染:从 DB show_when 列读取,格式 "param_key:value"
if (p.show_when) {
const [whenParam, whenValue] = p.show_when.split(':')
if (whenParam && whenValue !== undefined) {
param.showWhen = { param: whenParam, equals: whenValue }
}
}
return param
}),
// 额外 docker run 参数(含 {{}} 占位符,由 tplWithDerived 动态解析)
extraArgs: row.extra_args || undefined,
},
}
// 清理 undefined 字段
if (!tpl.deploy.extraArgs) delete tpl.deploy.extraArgs
return tpl
}
// ---------- 应用生命周期 ----------
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})