v0.0.2: Java应用模板、任务中心占位符修复、端口映射修复、WFJ Panel品牌统一
This commit is contained in:
+27
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user