feat: Docker集群管理改进 - 部署弹窗基础/高级模式、容器分类管理、节点端点预设检测、登录安全增强

This commit is contained in:
晴天
2026-03-08 13:44:00 +08:00
parent 5926bbb11b
commit b904fb2398
15 changed files with 2778 additions and 42 deletions

View File

@@ -3,7 +3,8 @@
*/
import { navigate, getCurrentRoute } from '../router.js'
import { toggleTheme, getTheme } from '../lib/theme.js'
import { isOpenclawReady } from '../lib/app-state.js'
import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange } from '../lib/app-state.js'
import { api } from '../lib/tauri-api.js'
import { version as APP_VERSION } from '../../package.json'
const NAV_ITEMS_FULL = [
@@ -39,6 +40,12 @@ const NAV_ITEMS_FULL = [
{ route: '/skills', label: 'Skills', icon: 'skills' },
]
},
{
section: 'Docker',
items: [
{ route: '/docker', label: 'Docker 集群', icon: 'docker' },
]
},
{
section: '',
items: [
@@ -87,14 +94,31 @@ const ICONS = {
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
docker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="11" width="4" height="3" rx=".5"/><rect x="6" y="11" width="4" height="3" rx=".5"/><rect x="11" y="11" width="4" height="3" rx=".5"/><rect x="6" y="7" width="4" height="3" rx=".5"/><rect x="11" y="7" width="4" height="3" rx=".5"/><rect x="16" y="11" width="4" height="3" rx=".5"/><rect x="11" y="3" width="4" height="3" rx=".5"/><path d="M2 17c1 3 4 5 10 5s9-2 10-5"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
}
let _delegated = false
let _hasMultipleInstances = false
// 异步检测是否有多实例(首次渲染后触发,有多实例时重渲染)
function _checkMultiInstances(el) {
api.instanceList().then(data => {
const has = data.instances && data.instances.length > 1
if (has !== _hasMultipleInstances) {
_hasMultipleInstances = has
renderSidebar(el)
}
}).catch(() => {})
}
export function renderSidebar(el) {
const current = getCurrentRoute()
const inst = getActiveInstance()
const isLocal = inst.type === 'local'
const showSwitcher = !isLocal || _hasMultipleInstances
let html = `
<div class="sidebar-header">
<div class="sidebar-logo">
@@ -102,6 +126,14 @@ export function renderSidebar(el) {
</div>
<span class="sidebar-title">ClawPanel</span>
</div>
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
<button class="instance-current" id="btn-instance-toggle">
<span class="instance-dot ${isLocal ? 'local' : 'remote'}"></span>
<span class="instance-label">${_escSidebar(inst.name)}</span>
<svg class="instance-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="instance-dropdown" id="instance-dropdown"></div>
</div>` : ''}
<nav class="sidebar-nav">
`
@@ -143,6 +175,9 @@ export function renderSidebar(el) {
el.innerHTML = html
// 首次渲染时异步检测多实例
if (!_delegated) _checkMultiInstances(el)
// 事件委托:只绑定一次,避免重复绑定
if (!_delegated) {
_delegated = true
@@ -158,7 +193,132 @@ export function renderSidebar(el) {
if (themeBtn) {
toggleTheme()
renderSidebar(el)
return
}
// 实例切换器
const toggleBtn = e.target.closest('#btn-instance-toggle')
if (toggleBtn) {
_toggleInstanceDropdown(el)
return
}
// 选择实例
const opt = e.target.closest('.instance-option[data-id]')
if (opt) {
const id = opt.dataset.id
_closeInstanceDropdown()
if (id !== getActiveInstance().id) {
opt.style.opacity = '0.5'
switchInstance(id).then(() => {
renderSidebar(el)
navigate(getCurrentRoute())
})
}
return
}
// 添加实例
const addBtn = e.target.closest('#btn-instance-add')
if (addBtn) {
_closeInstanceDropdown()
_showAddInstanceDialog(el)
return
}
// 点击其他区域关闭下拉
if (!e.target.closest('.instance-switcher')) {
_closeInstanceDropdown()
}
})
// 监听实例变化,刷新多实例标记后重新渲染
onInstanceChange(() => { _checkMultiInstances(el); renderSidebar(el) })
}
}
function _escSidebar(s) { return String(s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function _closeInstanceDropdown() {
const dd = document.getElementById('instance-dropdown')
if (dd) dd.classList.remove('open')
}
async function _toggleInstanceDropdown(sidebarEl) {
const dd = document.getElementById('instance-dropdown')
if (!dd) return
if (dd.classList.contains('open')) { dd.classList.remove('open'); return }
dd.innerHTML = '<div style="padding:8px;color:var(--text-tertiary);font-size:12px">loading...</div>'
dd.classList.add('open')
try {
const [data, health] = await Promise.all([api.instanceList(), api.instanceHealthAll()])
const healthMap = Object.fromEntries((health || []).map(h => [h.id, h]))
const activeId = getActiveInstance().id
let html = ''
for (const inst of data.instances) {
const h = healthMap[inst.id] || {}
const active = inst.id === activeId ? ' active' : ''
const dot = h.online !== false ? 'online' : 'offline'
const badge = inst.type === 'docker' ? '<span class="instance-badge">Docker</span>' : inst.type === 'remote' ? '<span class="instance-badge">Remote</span>' : ''
html += `<div class="instance-option${active}" data-id="${inst.id}">
<span class="instance-dot ${dot}"></span>
<span class="instance-opt-name">${_escSidebar(inst.name)}</span>
${badge}
</div>`
}
html += '<div class="instance-divider"></div>'
html += '<div class="instance-option instance-add" id="btn-instance-add">+ Add Instance</div>'
dd.innerHTML = html
} catch (e) {
dd.innerHTML = `<div style="padding:8px;color:var(--error);font-size:12px">${_escSidebar(e.message)}</div>`
}
}
async function _showAddInstanceDialog(sidebarEl) {
const overlay = document.createElement('div')
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title">Add Instance</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Name</label>
<input class="form-input" id="inst-name" placeholder="My Server" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Panel Endpoint</label>
<input class="form-input" id="inst-endpoint" placeholder="http://192.168.1.100:1420" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Gateway Port (optional)</label>
<input class="form-input" id="inst-gw-port" type="number" value="18789" />
</div>
<div class="docker-dialog-hint">
The remote server must be running ClawPanel (serve.js).<br/>
Example: <code>http://192.168.1.100:1420</code>
</div>
<div id="inst-add-error" style="color:var(--error);font-size:12px;margin-top:var(--space-sm)"></div>
<div class="docker-dialog-actions">
<button class="btn btn-secondary btn-sm" id="inst-cancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">Add</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('#inst-cancel').onclick = () => overlay.remove()
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
overlay.querySelector('#inst-confirm').onclick = async () => {
const name = overlay.querySelector('#inst-name').value.trim()
const endpoint = overlay.querySelector('#inst-endpoint').value.trim()
const gwPort = parseInt(overlay.querySelector('#inst-gw-port').value) || 18789
const errEl = overlay.querySelector('#inst-add-error')
if (!name || !endpoint) { errEl.textContent = 'Name and endpoint are required'; return }
const btn = overlay.querySelector('#inst-confirm')
btn.disabled = true; btn.textContent = 'Adding...'
try {
await api.instanceAdd({ name, type: 'remote', endpoint, gatewayPort: gwPort })
overlay.remove()
renderSidebar(sidebarEl)
} catch (e) {
errEl.textContent = e.message || String(e)
btn.disabled = false; btn.textContent = 'Add'
}
}
}

View File

@@ -7,6 +7,9 @@ import { api } from './tauri-api.js'
let _openclawReady = false
let _gatewayRunning = false
let _platform = '' // 'macos' | 'win32' | ...
let _deployMode = 'local' // 'local' | 'docker'
let _inDocker = false
let _dockerAvailable = false
let _listeners = []
let _gwListeners = []
let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用
@@ -54,6 +57,40 @@ export function isMacPlatform() {
return _platform === 'macos'
}
/** 部署模式 */
export function getDeployMode() { return _deployMode }
export function isInDocker() { return _inDocker }
export function isDockerAvailable() { return _dockerAvailable }
/** 实例管理 */
let _activeInstance = { id: 'local', name: '本机', type: 'local' }
let _instanceListeners = []
export function getActiveInstance() { return _activeInstance }
export function isLocalInstance() { return _activeInstance.type === 'local' }
export function onInstanceChange(fn) {
_instanceListeners.push(fn)
return () => { _instanceListeners = _instanceListeners.filter(cb => cb !== fn) }
}
export async function switchInstance(id) {
// instanceSetActive 内部已调用 _cache.clear(),切换后所有缓存自动失效
await api.instanceSetActive(id)
const data = await api.instanceList()
_activeInstance = data.instances.find(i => i.id === id) || data.instances[0]
_instanceListeners.forEach(fn => { try { fn(_activeInstance) } catch {} })
}
export async function loadActiveInstance() {
try {
const data = await api.instanceList()
_activeInstance = data.instances.find(i => i.id === data.activeId) || data.instances[0]
} catch {
_activeInstance = { id: 'local', name: '本机', type: 'local' }
}
}
/** 监听 Gateway 状态变化 */
export function onGatewayChange(fn) {
_gwListeners.push(fn)
@@ -71,6 +108,10 @@ export async function detectOpenclawStatus() {
if (installation.status === 'fulfilled' && installation.value?.platform) {
_platform = installation.value.platform
}
if (installation.status === 'fulfilled' && installation.value?.inDocker) {
_inDocker = true
_deployMode = 'docker'
}
const cliInstalled = services.status === 'fulfilled'
&& services.value?.length > 0
&& services.value[0]?.cli_installed !== false

View File

@@ -15,6 +15,23 @@ const NO_MOCK_CMDS = new Set([
'set_npm_registry', 'reload_gateway', 'restart_gateway',
'auto_pair_device',
'assistant_exec', 'assistant_write_file',
'docker_create_container', 'docker_start_container', 'docker_stop_container',
'docker_restart_container', 'docker_remove_container', 'docker_pull_image',
'docker_add_node', 'docker_remove_node',
'instance_add', 'instance_remove', 'instance_set_active',
])
// 仅在 Node.js 后端实现的命令Tauri Rust 不处理),强制走 webInvoke
const WEB_ONLY_CMDS = new Set([
'docker_test_endpoint',
'docker_info', 'docker_list_containers', 'docker_create_container',
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
'docker_remove_container', 'docker_container_logs', 'docker_pull_image',
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
'docker_cluster_overview',
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
'instance_health_check', 'instance_health_all',
'get_deploy_mode',
])
// 预加载 Tauri invoke避免每次 API 调用都做动态 import
@@ -79,7 +96,7 @@ export { invalidate }
async function invoke(cmd, args = {}) {
const start = Date.now()
if (_invokeReady) {
if (_invokeReady && !WEB_ONLY_CMDS.has(cmd)) {
const tauriInvoke = await _invokeReady
const result = await tauriInvoke(cmd, args)
const duration = Date.now() - start
@@ -113,8 +130,9 @@ async function webInvoke(cmd, args) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
if (resp.status === 401 && window.__clawpanel_show_login) {
window.__clawpanel_show_login()
if (resp.status === 401) {
// Tauri 模式下不触发登录浮层Tauri 有自己的认证流程)
if (!isTauri && window.__clawpanel_show_login) window.__clawpanel_show_login()
throw new Error('需要登录')
}
if (!resp.ok) {
@@ -193,7 +211,28 @@ function mockInvoke(cmd, args) {
write_memory_file: () => true,
delete_memory_file: () => true,
export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`,
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }),
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23', inDocker: false }),
get_deploy_mode: () => ({ inDocker: false, dockerAvailable: true, mode: 'local' }),
docker_cluster_overview: () => [
{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock', online: true, dockerVersion: '27.4.1', os: 'Docker Desktop', cpus: 8, memory: 16 * 1024 * 1024 * 1024, totalContainers: 3, runningContainers: 2, stoppedContainers: 1, containers: [
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789' },
{ id: 'f6e5d4c3b2a1', name: 'openclaw-gw-2', image: 'ghcr.io/qingchencloud/openclaw:latest-gateway', state: 'running', status: 'Up 5 hours', ports: '18790→18789' },
{ id: 'b3c4d5e6f7a8', name: 'openclaw-test', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'exited', status: 'Exited (0) 1 day ago', ports: '' },
]},
],
docker_list_containers: () => [
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789', nodeId: 'local', nodeName: '本机' },
],
docker_info: () => ({ nodeId: 'local', nodeName: '本机', containers: 3, containersRunning: 2, containersStopped: 1, images: 5, serverVersion: '27.4.1', os: 'Docker Desktop', arch: 'x86_64', cpus: 8, memory: 16 * 1024 * 1024 * 1024 }),
docker_list_nodes: () => [{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock' }],
docker_container_logs: () => '[INFO] OpenClaw Gateway started\n[INFO] Listening on :18789\n[INFO] Panel available at :1420',
docker_list_images: () => [{ id: 'sha256abcdef', tags: ['ghcr.io/qingchencloud/openclaw:latest'], size: 450 * 1024 * 1024, created: Date.now() / 1000 - 86400 }],
instance_list: () => ({ activeId: 'local', instances: [{ id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }] }),
instance_add: () => ({ id: 'remote-mock', name: 'mock' }),
instance_remove: () => true,
instance_set_active: () => ({ activeId: 'local' }),
instance_health_check: () => ({ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }),
instance_health_all: () => [{ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }],
check_node: () => ({ installed: true, version: 'v20.11.0' }),
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
read_mcp_config: () => ({
@@ -362,6 +401,32 @@ export const api = {
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
// 实例管理
instanceList: () => cachedInvoke('instance_list', {}, 10000),
instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) },
instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) },
instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) },
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
instanceHealthAll: () => invoke('instance_health_all'),
// Docker 集群管理
getDeployMode: () => cachedInvoke('get_deploy_mode', {}, 60000),
dockerClusterOverview: () => invoke('docker_cluster_overview'),
dockerTestEndpoint: (endpoint) => invoke('docker_test_endpoint', { endpoint }),
dockerInfo: (nodeId) => invoke('docker_info', { nodeId }),
dockerListContainers: (nodeId, all = true) => invoke('docker_list_containers', { nodeId, all }),
dockerCreateContainer: (opts) => invoke('docker_create_container', opts),
dockerStartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_start_container', { nodeId, containerId }) },
dockerStopContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_stop_container', { nodeId, containerId }) },
dockerRestartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_restart_container', { nodeId, containerId }) },
dockerRemoveContainer: (nodeId, containerId, force = false) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_remove_container', { nodeId, containerId, force }) },
dockerContainerLogs: (nodeId, containerId, tail = 200) => invoke('docker_container_logs', { nodeId, containerId, tail }),
dockerPullImage: (nodeId, image, tag) => invoke('docker_pull_image', { nodeId, image, tag }),
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId }),
dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
dockerAddNode: (name, endpoint) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_add_node', { name, endpoint }) },
dockerRemoveNode: (nodeId) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_remove_node', { nodeId }) },
// 前端热更新
checkFrontendUpdate: () => invoke('check_frontend_update'),
downloadFrontendUpdate: (url, expectedHash) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '' }),

View File

@@ -4,7 +4,7 @@
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
import { wsClient } from './lib/ws-client.js'
import { api } from './lib/tauri-api.js'
import { version as APP_VERSION } from '../package.json'
@@ -60,10 +60,20 @@ function _hideSplash() {
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500) }
}
let _loginFailCount = 0
const CAPTCHA_THRESHOLD = 3
function _genCaptcha() {
const a = Math.floor(Math.random() * 20) + 1
const b = Math.floor(Math.random() * 20) + 1
return { q: `${a} + ${b} = ?`, a: a + b }
}
function showLoginOverlay(defaultPw) {
const hasDefault = !!defaultPw
const overlay = document.createElement('div')
overlay.id = 'login-overlay'
let _captcha = _loginFailCount >= CAPTCHA_THRESHOLD ? _genCaptcha() : null
overlay.innerHTML = `
<div class="login-card">
${_logoSvg}
@@ -73,10 +83,23 @@ function showLoginOverlay(defaultPw) {
: (isTauri ? '应用已锁定,请输入密码' : '请输入访问密码')}</div>
<form id="login-form">
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="访问密码" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
<div id="login-captcha" style="display:${_captcha ? 'block' : 'none'};margin-bottom:10px">
<div style="font-size:12px;color:#888;margin-bottom:6px">请先完成验证:<strong id="captcha-q" style="color:var(--text-primary,#333)">${_captcha ? _captcha.q : ''}</strong></div>
<input class="login-input" type="number" id="login-captcha-input" placeholder="输入计算结果" style="text-align:center" />
</div>
<button class="login-btn" type="submit">登 录</button>
<div class="login-error" id="login-error"></div>
</form>
<div style="margin-top:20px;font-size:11px;color:#aaa;text-align:center">
${!hasDefault ? `<details class="login-forgot" style="margin-top:16px;text-align:center">
<summary style="font-size:11px;color:#aaa;cursor:pointer;list-style:none;user-select:none">忘记密码?</summary>
<div style="margin-top:8px;font-size:11px;color:#888;line-height:1.8;text-align:left;background:rgba(0,0,0,.03);border-radius:8px;padding:10px 14px">
${isTauri
? '删除配置文件中的 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段即可重置:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
: '编辑服务器上的配置文件,删除 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段后重启服务:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
}
</div>
</details>` : ''}
<div style="margin-top:${hasDefault ? '20' : '12'}px;font-size:11px;color:#aaa;text-align:center">
<a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:#aaa;text-decoration:none">claw.qt.cool</a>
<span style="margin:0 6px">·</span>v${APP_VERSION}
</div>
@@ -94,18 +117,46 @@ function showLoginOverlay(defaultPw) {
btn.disabled = true
btn.textContent = '登录中...'
errEl.textContent = ''
// 验证码校验
if (_captcha) {
const captchaVal = parseInt(overlay.querySelector('#login-captcha-input')?.value)
if (captchaVal !== _captcha.a) {
errEl.textContent = '验证码错误'
_captcha = _genCaptcha()
const qEl = overlay.querySelector('#captcha-q')
if (qEl) qEl.textContent = _captcha.q
overlay.querySelector('#login-captcha-input').value = ''
btn.disabled = false
btn.textContent = '登 录'
return
}
}
try {
if (isTauri) {
// 桌面端:本地比对密码
const { api } = await import('./lib/tauri-api.js')
const cfg = await api.readPanelConfig()
if (pw !== cfg.accessPassword) {
errEl.textContent = '密码错误'
_loginFailCount++
if (_loginFailCount >= CAPTCHA_THRESHOLD && !_captcha) {
_captcha = _genCaptcha()
const cEl = overlay.querySelector('#login-captcha')
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
}
errEl.textContent = `密码错误${_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`}`
btn.disabled = false
btn.textContent = '登 录'
return
}
sessionStorage.setItem('clawpanel_authed', '1')
// 同步建立 web sessionWEB_ONLY_CMDS 需要 cookie 认证)
try {
await fetch('/__api/auth_login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
})
} catch {}
overlay.classList.add('hide')
setTimeout(() => overlay.remove(), 400)
if (cfg.accessPassword === '123456') {
@@ -121,7 +172,13 @@ function showLoginOverlay(defaultPw) {
})
const data = await resp.json()
if (!resp.ok) {
errEl.textContent = data.error || '登录失败'
_loginFailCount++
if (_loginFailCount >= CAPTCHA_THRESHOLD && !_captcha) {
_captcha = _genCaptcha()
const cEl = overlay.querySelector('#login-captcha')
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
}
errEl.textContent = (data.error || '登录失败') + (_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`)
btn.disabled = false
btn.textContent = '登 录'
return
@@ -169,6 +226,7 @@ async function boot() {
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/assistant', () => import('./pages/assistant.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
registerRoute('/docker', () => import('./pages/docker.js'))
renderSidebar(sidebar)
initRouter(content)
@@ -193,8 +251,20 @@ async function boot() {
document.body.prepend(banner)
}
// 后台检测状态,检测完再决定是否跳转 setup
detectOpenclawStatus().then(() => {
// Tauri 模式:确保 web session 存在(页面刷新后 cookie 可能丢失),然后加载实例和检测状态
const ensureWebSession = isTauri
? api.readPanelConfig().then(cfg => {
if (cfg.accessPassword) {
return fetch('/__api/auth_login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: cfg.accessPassword }),
}).catch(() => {})
}
}).catch(() => {})
: Promise.resolve()
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
renderSidebar(sidebar)
if (!isOpenclawReady()) {
@@ -223,13 +293,21 @@ async function boot() {
onGuardianGiveUp(() => {
showGuardianRecovery()
})
// 实例切换时,重连 WebSocket + 重新检测状态
onInstanceChange(async () => {
wsClient.disconnect()
await detectOpenclawStatus()
if (isGatewayRunning()) autoConnectWebSocket()
})
}
})
}
async function autoConnectWebSocket() {
try {
console.log('[main] 自动连接 WebSocket...')
const inst = getActiveInstance()
console.log(`[main] 自动连接 WebSocket (实例: ${inst.name})...`)
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
@@ -270,9 +348,20 @@ async function autoConnectWebSocket() {
}
}
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
let host
const inst2 = getActiveInstance()
if (inst2.type !== 'local' && inst2.endpoint) {
try {
const url = new URL(inst2.endpoint)
host = `${url.hostname}:${inst2.gatewayPort || port}`
} catch {
host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
}
} else {
host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
}
wsClient.connect(host, token)
console.log('[main] WebSocket 连接已启动')
console.log(`[main] WebSocket 连接已启动 -> ${host}`)
} catch (e) {
console.error('[main] 自动连接 WebSocket 失败:', e)
}
@@ -382,11 +471,118 @@ function showGuardianRecovery() {
})
}
// === 全局版本更新检测 ===
const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000 // 30 分钟
let _updateCheckTimer = null
async function checkGlobalUpdate() {
const banner = document.getElementById('update-banner')
if (!banner) return
try {
const info = await api.checkFrontendUpdate()
if (!info.hasUpdate) return
const ver = info.latestVersion || info.manifest?.version || ''
if (!ver) return
// 用户已忽略过该版本,不再打扰
const dismissed = sessionStorage.getItem('clawpanel_update_dismissed')
if (dismissed === ver) return
const changelog = info.manifest?.changelog || ''
const isWeb = !window.__TAURI_INTERNALS__
banner.classList.remove('update-banner-hidden')
banner.innerHTML = `
<div class="update-banner-content">
<div class="update-banner-text">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span class="update-banner-ver">ClawPanel v${ver} 可用</span>
${changelog ? `<span class="update-banner-changelog">· ${changelog}</span>` : ''}
</div>
${isWeb
? `<button class="btn btn-sm" id="btn-update-show-cmd">更新方法</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">Release Notes</a>`
: `<button class="btn btn-sm" id="btn-update-hot">热更新</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">完整安装包</a>`
}
<button class="update-banner-close" id="btn-update-dismiss" title="忽略此版本">✕</button>
</div>
`
// 关闭按钮:记住忽略的版本
banner.querySelector('#btn-update-dismiss')?.addEventListener('click', () => {
sessionStorage.setItem('clawpanel_update_dismissed', ver)
banner.classList.add('update-banner-hidden')
})
// Web 模式:显示更新命令弹窗
banner.querySelector('#btn-update-show-cmd')?.addEventListener('click', () => {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:480px">
<div class="modal-title">更新到 v${ver}</div>
<div style="font-size:var(--font-size-sm);line-height:1.8">
<p style="margin-bottom:12px">在服务器上执行以下命令:</p>
<pre style="background:var(--bg-tertiary);padding:12px 16px;border-radius:var(--radius-md);font-family:var(--font-mono);font-size:var(--font-size-xs);overflow-x:auto;white-space:pre-wrap;user-select:all">cd /opt/clawpanel
git pull origin main
npm install
npm run build
sudo systemctl restart clawpanel</pre>
<p style="margin-top:12px;color:var(--text-tertiary);font-size:var(--font-size-xs)">
如果 git pull 失败,可先执行 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 丢弃本地修改。<br>
路径请替换为实际的 ClawPanel 安装目录。
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
overlay.querySelector('[data-action="close"]').onclick = () => overlay.remove()
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove() })
})
// Tauri 热更新按钮
banner.querySelector('#btn-update-hot')?.addEventListener('click', async () => {
const btn = banner.querySelector('#btn-update-hot')
if (!btn) return
btn.disabled = true
btn.textContent = '下载中...'
try {
await api.downloadFrontendUpdate(info.manifest?.url || '', info.manifest?.hash || '')
btn.textContent = '重载应用'
btn.disabled = false
btn.onclick = () => window.location.reload()
} catch (e) {
btn.textContent = '下载失败'
btn.disabled = false
const { toast } = await import('./components/toast.js')
toast('更新下载失败: ' + (e.message || e), 'error')
}
})
} catch {
// 检查失败静默忽略
}
}
function startUpdateChecker() {
// 启动后 5 秒检查一次
setTimeout(checkGlobalUpdate, 5000)
// 之后每 30 分钟检查一次
_updateCheckTimer = setInterval(checkGlobalUpdate, UPDATE_CHECK_INTERVAL)
}
// 启动:先检查认证,再加载应用
;(async () => {
const auth = await checkAuth()
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
boot()
startUpdateChecker()
// 初始化全局 AI 助手浮动按钮(延迟加载,不阻塞启动)
setTimeout(async () => {

View File

@@ -64,12 +64,12 @@ async function loadData(page) {
])
// 尝试从 Tauri API 获取 ClawPanel 自身版本号,失败则 fallback
let panelVersion = '0.1.0'
let panelVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.0'
try {
const { getVersion } = await import('@tauri-apps/api/app')
panelVersion = await getVersion()
} catch {
// 非 Tauri 环境或 API 不可用,使用 fallback
// 非 Tauri 环境或 API 不可用,使用构建时注入的版本号
}
// 异步检查前端热更新

618
src/pages/docker.js Normal file
View File

@@ -0,0 +1,618 @@
/**
* Docker 集群管理页面
* 管理 OpenClaw Docker 容器集群:节点管理、容器 CRUD、日志查看
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
function esc(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function fmtBytes(bytes) {
if (!bytes) return '-'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
return (bytes / 1073741824).toFixed(1) + ' GB'
}
// OpenClaw 容器识别
const OPENCLAW_PATTERNS = ['openclaw', 'qingchencloud']
function isOpenClawContainer(c) {
const img = (c.image || '').toLowerCase()
return OPENCLAW_PATTERNS.some(p => img.includes(p))
}
// 用户手动纳入管理的容器 ID 持久化
const ADOPTED_KEY = 'clawpanel_adopted_containers'
function getAdoptedIds() {
try { return new Set(JSON.parse(localStorage.getItem(ADOPTED_KEY) || '[]')) }
catch { return new Set() }
}
function saveAdoptedIds(ids) {
localStorage.setItem(ADOPTED_KEY, JSON.stringify([...ids]))
}
function isManagedContainer(c) {
return isOpenClawContainer(c) || getAdoptedIds().has(c.id)
}
let _refreshTimer = null
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">Docker 集群管理</h1>
<p class="page-desc">管理 OpenClaw Docker 容器集群,快速部署和扩展</p>
</div>
<div id="docker-stats" class="stat-cards"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
<div id="docker-nodes" style="margin-top:var(--space-lg)"><div class="stat-card loading-placeholder" style="height:120px"></div></div>
<div id="docker-containers" style="margin-top:var(--space-lg)"><div class="stat-card loading-placeholder" style="height:200px"></div></div>
`
bindEvents(page)
await loadClusterOverview(page)
_refreshTimer = setInterval(() => loadClusterOverview(page), 30000)
return page
}
export function cleanup() {
if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null }
}
async function loadClusterOverview(page) {
try {
const nodes = await api.dockerClusterOverview()
renderStats(page, nodes)
renderNodes(page, nodes)
renderContainers(page, nodes)
} catch (e) {
const statsEl = page.querySelector('#docker-stats')
statsEl.innerHTML = `
<div class="docker-empty">
<div class="docker-empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><rect x="1" y="11" width="4" height="3" rx=".5"/><rect x="6" y="11" width="4" height="3" rx=".5"/><rect x="11" y="11" width="4" height="3" rx=".5"/><rect x="6" y="7" width="4" height="3" rx=".5"/><rect x="11" y="7" width="4" height="3" rx=".5"/><rect x="16" y="11" width="4" height="3" rx=".5"/><rect x="11" y="3" width="4" height="3" rx=".5"/><path d="M2 17c1 3 4 5 10 5s9-2 10-5"/></svg>
</div>
<div class="docker-empty-title">Docker 未连接</div>
<div class="docker-empty-desc">${esc(e.message)}</div>
<div class="docker-empty-hint">
<p>确保 Docker 已安装并运行:</p>
<code>docker info</code>
<p style="margin-top:8px">如果在 Docker 容器内运行,请挂载 Docker Socket</p>
<code>-v /var/run/docker.sock:/var/run/docker.sock</code>
</div>
</div>
`
page.querySelector('#docker-nodes').innerHTML = ''
page.querySelector('#docker-containers').innerHTML = ''
}
}
function renderStats(page, nodes) {
const el = page.querySelector('#docker-stats')
const totalNodes = nodes.length
const onlineNodes = nodes.filter(n => n.online).length
// 统计托管容器OpenClaw + 手动纳入)
let managedTotal = 0, managedRunning = 0, managedStopped = 0
for (const n of nodes) {
if (!n.online || !n.containers) continue
for (const c of n.containers) {
if (isManagedContainer(c)) {
managedTotal++
if (c.state === 'running') managedRunning++
else managedStopped++
}
}
}
el.innerHTML = `
<div class="stat-card">
<div class="stat-card-value">${onlineNodes}<span class="stat-card-unit">/ ${totalNodes}</span></div>
<div class="stat-card-label">节点在线</div>
</div>
<div class="stat-card">
<div class="stat-card-value">${managedTotal}</div>
<div class="stat-card-label">托管容器</div>
</div>
<div class="stat-card">
<div class="stat-card-value" style="color:var(--success, #22c55e)">${managedRunning}</div>
<div class="stat-card-label">运行中</div>
</div>
<div class="stat-card">
<div class="stat-card-value" style="color:var(--text-tertiary)">${managedStopped}</div>
<div class="stat-card-label">已停止</div>
</div>
`
}
function renderNodes(page, nodes) {
const el = page.querySelector('#docker-nodes')
let html = `
<div class="docker-section-header">
<div class="docker-section-title">节点管理</div>
<div class="docker-section-actions">
<button class="btn btn-primary btn-sm" data-action="add-node">+ 添加节点</button>
</div>
</div>
<div class="docker-node-grid">
`
for (const node of nodes) {
const statusClass = node.online ? 'online' : 'offline'
const statusText = node.online ? '在线' : '离线'
const mem = node.memory ? fmtBytes(node.memory) : '-'
html += `
<div class="docker-node-card ${statusClass}">
<div class="docker-node-header">
<div class="docker-node-status ${statusClass}"></div>
<div class="docker-node-name">${esc(node.name)}</div>
<div class="docker-node-badge">${statusText}</div>
${node.id !== 'local' ? `<button class="docker-node-remove" data-action="remove-node" data-node-id="${esc(node.id)}" title="移除节点">&times;</button>` : ''}
</div>
<div class="docker-node-info">
<span>${esc(node.endpoint)}</span>
${node.online ? `<span>Docker ${esc(node.dockerVersion)}</span><span>${node.cpus || '-'} CPU · ${mem} RAM</span>` : `<span class="docker-node-error">${esc(node.error || '连接失败')}</span>`}
</div>
${node.online ? `
<div class="docker-node-footer">
<span>${node.runningContainers || 0} 运行 / ${node.totalContainers || 0} 总计</span>
<button class="btn btn-sm" data-action="deploy" data-node-id="${esc(node.id)}">部署容器</button>
</div>
` : ''}
</div>
`
}
html += '</div>'
el.innerHTML = html
}
function _renderContainerRow(c, showAdopt) {
const isRunning = c.state === 'running'
const stateClass = isRunning ? 'running' : 'stopped'
const isAdopted = !isOpenClawContainer(c) && getAdoptedIds().has(c.id)
return `<tr>
<td><span class="docker-ct-name">${esc(c.name)}</span><span class="docker-ct-id">${esc(c.id)}</span></td>
<td class="docker-ct-image">${esc(c.image)}</td>
<td><span class="docker-ct-state ${stateClass}">${esc(c.status || c.state)}</span></td>
<td class="docker-ct-ports">${esc(c.ports) || '-'}</td>
<td class="docker-ct-actions">
${showAdopt ? `
<button class="btn btn-sm" data-action="adopt" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}">纳入管理</button>
` : `
${isRunning
? `<button class="btn-icon" data-action="stop" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="停止">⏹</button>
<button class="btn-icon" data-action="restart" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="重启">🔄</button>`
: `<button class="btn-icon" data-action="start" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="启动">▶</button>`
}
<button class="btn-icon" data-action="logs" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="日志">📋</button>
${isAdopted ? `<button class="btn-icon" data-action="unadopt" data-ct="${esc(c.id)}" title="取消管理">✕</button>` : ''}
<button class="btn-icon danger" data-action="remove" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}" title="删除">🗑</button>
`}
</td>
</tr>`
}
function renderContainers(page, nodes) {
const el = page.querySelector('#docker-containers')
const allContainers = []
for (const node of nodes) {
if (!node.online || !node.containers) continue
for (const c of node.containers) {
allContainers.push({ ...c, nodeId: node.id, nodeName: node.name })
}
}
const managed = allContainers.filter(c => isManagedContainer(c))
const other = allContainers.filter(c => !isManagedContainer(c))
let html = `
<div class="docker-section-header">
<div class="docker-section-title">OpenClaw 容器</div>
<div class="docker-section-actions">
<button class="btn btn-sm" data-action="refresh">刷新</button>
</div>
</div>
`
if (managed.length === 0) {
html += `<div class="docker-empty-inline">暂无 OpenClaw 容器,点击节点上的「部署容器」创建,或从下方已有容器中纳入管理</div>`
} else {
html += `<div class="docker-table-wrap"><table class="docker-table">
<thead><tr>
<th>名称</th><th>镜像</th><th>状态</th><th>端口</th><th>操作</th>
</tr></thead><tbody>`
for (const c of managed) html += _renderContainerRow(c, false)
html += '</tbody></table></div>'
}
// 其他容器(可折叠)
if (other.length > 0) {
html += `
<details class="docker-other-section" style="margin-top:var(--space-lg)">
<summary class="docker-other-toggle">
<span>其他 Docker 容器</span>
<span class="docker-other-count">${other.length}</span>
</summary>
<div class="docker-table-wrap" style="margin-top:var(--space-sm)"><table class="docker-table">
<thead><tr>
<th>名称</th><th>镜像</th><th>状态</th><th>端口</th><th>操作</th>
</tr></thead><tbody>
${other.map(c => _renderContainerRow(c, true)).join('')}
</tbody></table></div>
</details>
`
}
el.innerHTML = html
}
function bindEvents(page) {
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
if (action === 'refresh') {
toast('刷新中...')
await loadClusterOverview(page)
return
}
if (action === 'add-node') {
showAddNodeDialog(page)
return
}
if (action === 'remove-node') {
const nodeId = btn.dataset.nodeId
const ok = await showConfirm('确定移除此节点?', '移除后该节点上的容器将不再在此面板中管理。')
if (!ok) return
try {
await api.dockerRemoveNode(nodeId)
toast('节点已移除')
await loadClusterOverview(page)
} catch (e) { toast(e.message, 'error') }
return
}
if (action === 'deploy') {
showDeployDialog(page, btn.dataset.nodeId)
return
}
if (action === 'adopt') {
const ids = getAdoptedIds()
ids.add(btn.dataset.ct)
saveAdoptedIds(ids)
toast(`已将 ${btn.dataset.name || btn.dataset.ct} 纳入管理`)
await loadClusterOverview(page)
return
}
if (action === 'unadopt') {
const ids = getAdoptedIds()
ids.delete(btn.dataset.ct)
saveAdoptedIds(ids)
toast('已取消管理')
await loadClusterOverview(page)
return
}
const containerId = btn.dataset.ct
const nodeId = btn.dataset.node
if (action === 'start') {
try {
btn.disabled = true
await api.dockerStartContainer(nodeId, containerId)
toast('容器已启动')
await loadClusterOverview(page)
} catch (e) { toast(e.message, 'error') }
return
}
if (action === 'stop') {
try {
btn.disabled = true
await api.dockerStopContainer(nodeId, containerId)
toast('容器已停止')
await loadClusterOverview(page)
} catch (e) { toast(e.message, 'error') }
return
}
if (action === 'restart') {
try {
btn.disabled = true
await api.dockerRestartContainer(nodeId, containerId)
toast('容器已重启')
await loadClusterOverview(page)
} catch (e) { toast(e.message, 'error') }
return
}
if (action === 'remove') {
const name = btn.dataset.name || containerId
const ok = await showConfirm(`删除容器 ${name}`, '容器数据卷将保留,但容器本身将被删除。')
if (!ok) return
try {
await api.dockerRemoveContainer(nodeId, containerId, true)
toast('容器已删除')
await loadClusterOverview(page)
} catch (e) { toast(e.message, 'error') }
return
}
if (action === 'logs') {
showLogsDialog(page, nodeId, containerId)
return
}
})
}
function showAddNodeDialog(page) {
const isWin = navigator.platform?.toLowerCase().includes('win')
const presets = [
{ label: '本机 (TCP)', endpoint: 'tcp://127.0.0.1:2375', desc: '本机 Docker TCP 端口' },
{ label: '本机 (Socket)', endpoint: isWin ? '//./pipe/docker_engine' : 'unix:///var/run/docker.sock', desc: isWin ? 'Windows Named Pipe' : 'Unix Socket' },
]
const overlay = document.createElement('div')
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title">添加 Docker 节点</div>
<div class="form-group">
<label class="form-label">节点名称</label>
<input class="form-input" id="dn-name" placeholder="如:生产服务器" />
</div>
<div class="form-group">
<label class="form-label">Docker 端点</label>
<div class="dn-presets">
${presets.map((p, i) => `<button class="dn-preset-btn" data-idx="${i}" title="${esc(p.desc)}">${esc(p.label)}</button>`).join('')}
<button class="dn-preset-btn" data-idx="custom">自定义</button>
</div>
<div id="dn-endpoint-row" style="display:flex;gap:8px;align-items:center;margin-top:8px">
<input class="form-input" id="dn-endpoint" placeholder="tcp://192.168.1.100:2375" style="flex:1" />
<button class="btn btn-sm" id="dn-test" type="button" style="white-space:nowrap">测试连接</button>
</div>
<div id="dn-test-result" style="font-size:12px;margin-top:6px;min-height:18px"></div>
</div>
<div class="docker-dialog-hint">
<strong>远程 Docker</strong>需在目标机器开启 TCP 端口<br>
<code>dockerd -H tcp://0.0.0.0:2375</code>
</div>
<div class="docker-dialog-actions">
<button class="btn" data-dismiss>取消</button>
<button class="btn btn-primary" id="dn-submit">添加</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
const epInput = overlay.querySelector('#dn-endpoint')
const resultEl = overlay.querySelector('#dn-test-result')
// 预设按钮点击
for (const btn of overlay.querySelectorAll('.dn-preset-btn')) {
btn.onclick = () => {
overlay.querySelectorAll('.dn-preset-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
const idx = btn.dataset.idx
if (idx === 'custom') {
epInput.value = ''
epInput.focus()
} else {
epInput.value = presets[parseInt(idx)].endpoint
}
resultEl.textContent = ''
}
}
// 测试连接
overlay.querySelector('#dn-test').onclick = async () => {
const ep = epInput.value.trim()
if (!ep) { resultEl.innerHTML = '<span style="color:var(--error,#ef4444)">请先输入端点</span>'; return }
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">连接中...</span>'
try {
const info = await api.dockerTestEndpoint(ep)
resultEl.innerHTML = `<span style="color:var(--success,#22c55e)">✓ 连接成功 — Docker ${esc(info.ServerVersion || '?')}${info.Containers || 0} 个容器</span>`
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--error,#ef4444)">✕ 连接失败:${esc(e.message)}</span>`
}
}
overlay.querySelector('#dn-submit').onclick = async () => {
const name = overlay.querySelector('#dn-name').value.trim()
const endpoint = epInput.value.trim()
if (!name || !endpoint) { toast('请填写完整', 'error'); return }
const btn = overlay.querySelector('#dn-submit')
btn.disabled = true
btn.textContent = '连接中...'
try {
await api.dockerAddNode(name, endpoint)
toast('节点添加成功')
overlay.remove()
await loadClusterOverview(page)
} catch (e) {
toast(e.message, 'error')
btn.disabled = false
btn.textContent = '添加'
}
}
}
async function showDeployDialog(page, nodeId) {
// 自动检测已用端口,分配下一组可用端口
let usedPorts = new Set()
try {
const containers = await api.dockerListContainers(nodeId, true)
for (const c of containers) {
if (c.ports) {
for (const p of c.ports.split(', ')) {
const m = p.match(/^(\d+)/)
if (m) usedPorts.add(parseInt(m[1]))
}
}
}
} catch {}
let autoPanel = 1421
while (usedPorts.has(autoPanel)) autoPanel++
let autoGw = 18790
while (usedPorts.has(autoGw)) autoGw++
const defaultName = `openclaw-${Date.now().toString(36).slice(-4)}`
const defaultImage = 'ghcr.io/qingchencloud/openclaw:latest'
const overlay = document.createElement('div')
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title" style="display:flex;align-items:center;justify-content:space-between">
<span>部署 OpenClaw 容器</span>
<div class="deploy-mode-toggle">
<button class="deploy-mode-btn active" data-mode="basic">基础</button>
<button class="deploy-mode-btn" data-mode="advanced">高级</button>
</div>
</div>
<div class="form-group">
<label class="form-label">容器名称</label>
<input class="form-input" id="dd-name" placeholder="给你的 OpenClaw 起个名字" value="${defaultName}" />
</div>
<div id="deploy-basic-info" class="deploy-auto-summary">
<div class="deploy-auto-title">自动配置</div>
<div class="deploy-auto-item"><span>镜像</span><span>一体版 (latest)</span></div>
<div class="deploy-auto-item"><span>面板端口</span><span>${autoPanel}</span></div>
<div class="deploy-auto-item"><span>Gateway 端口</span><span>${autoGw}</span></div>
<div class="deploy-auto-item"><span>数据卷</span><span>自动创建</span></div>
<div class="deploy-auto-item"><span>重启策略</span><span>unless-stopped</span></div>
</div>
<div id="deploy-advanced-fields" style="display:none">
<div class="form-group">
<label class="form-label">镜像</label>
<select class="form-input" id="dd-image">
<option value="ghcr.io/qingchencloud/openclaw:latest">一体版 (latest)</option>
<option value="ghcr.io/qingchencloud/openclaw:latest-gateway">纯 Gateway (gateway)</option>
<option value="ccr.ccs.tencentyun.com/qingchencloud/openclaw:latest">一体版 - 国内源 (腾讯云)</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-sm)">
<div class="form-group">
<label class="form-label">面板端口</label>
<input class="form-input" id="dd-panel-port" type="number" value="${autoPanel}" />
</div>
<div class="form-group">
<label class="form-label">Gateway 端口</label>
<input class="form-input" id="dd-gw-port" type="number" value="${autoGw}" />
</div>
</div>
<div class="form-group">
<label class="form-label">环境变量 <span style="color:var(--text-tertiary)">(可选)</span></label>
<textarea class="form-input" id="dd-env-key" rows="2" placeholder="OPENAI_API_KEY=sk-xxx" style="resize:vertical;font-family:var(--font-mono);font-size:12px"></textarea>
<div class="form-hint">格式KEY=VALUE每行一个</div>
</div>
</div>
<div class="docker-dialog-actions">
<button class="btn" data-dismiss>取消</button>
<button class="btn btn-primary" id="dd-submit">一键部署</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
// 基础/高级模式切换
let isAdvanced = false
for (const btn of overlay.querySelectorAll('.deploy-mode-btn')) {
btn.onclick = () => {
isAdvanced = btn.dataset.mode === 'advanced'
overlay.querySelectorAll('.deploy-mode-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
overlay.querySelector('#deploy-basic-info').style.display = isAdvanced ? 'none' : ''
overlay.querySelector('#deploy-advanced-fields').style.display = isAdvanced ? '' : 'none'
overlay.querySelector('#dd-submit').textContent = isAdvanced ? '部署' : '一键部署'
}
}
overlay.querySelector('#dd-submit').onclick = async () => {
const name = overlay.querySelector('#dd-name').value.trim()
if (!name) { toast('请输入容器名称', 'error'); return }
let image, tag, panelPort, gatewayPort, envVars = {}
if (isAdvanced) {
const imgFull = overlay.querySelector('#dd-image').value
const parts = imgFull.split(':')
tag = parts.pop()
image = parts.join(':')
panelPort = parseInt(overlay.querySelector('#dd-panel-port').value) || autoPanel
gatewayPort = parseInt(overlay.querySelector('#dd-gw-port').value) || autoGw
const envText = overlay.querySelector('#dd-env-key').value.trim()
if (envText) {
for (const line of envText.split('\n')) {
const idx = line.indexOf('=')
if (idx > 0) envVars[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
}
}
} else {
const parts = defaultImage.split(':')
tag = parts.pop()
image = parts.join(':')
panelPort = autoPanel
gatewayPort = autoGw
}
const btn = overlay.querySelector('#dd-submit')
btn.disabled = true
btn.textContent = '部署中...'
try {
const result = await api.dockerCreateContainer({ nodeId, name, image, tag, panelPort, gatewayPort, envVars })
toast(`容器 ${result.name} 已部署并启动`)
overlay.remove()
await loadClusterOverview(page)
} catch (e) {
toast(e.message, 'error')
btn.disabled = false
btn.textContent = isAdvanced ? '部署' : '一键部署'
}
}
}
async function showLogsDialog(page, nodeId, containerId) {
const overlay = document.createElement('div')
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog docker-dialog-wide">
<div class="docker-dialog-title">容器日志 <span style="color:var(--text-tertiary);font-size:12px">${esc(containerId)}</span></div>
<pre class="docker-logs-content">加载中...</pre>
<div class="docker-dialog-actions">
<button class="btn" id="dl-refresh">刷新</button>
<button class="btn" data-dismiss>关闭</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
async function loadLogs() {
const pre = overlay.querySelector('.docker-logs-content')
try {
const logs = await api.dockerContainerLogs(nodeId, containerId, 200)
pre.textContent = logs || '(暂无日志)'
pre.scrollTop = pre.scrollHeight
} catch (e) {
pre.textContent = '获取日志失败: ' + e.message
}
}
await loadLogs()
overlay.querySelector('#dl-refresh').onclick = loadLogs
}

View File

@@ -5,7 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
@@ -44,18 +44,21 @@ export async function render() {
</div>
`
// Docker 模式下隐藏 npm 源设置
if (isInDocker()) {
const regSection = page.querySelector('#registry-section')
if (regSection) regSection.style.display = 'none'
}
bindEvents(page)
loadAll(page)
return page
}
async function loadAll(page) {
await Promise.all([
loadVersion(page),
loadServices(page),
loadRegistry(page),
loadBackups(page),
])
const tasks = [loadVersion(page), loadServices(page), loadBackups(page)]
if (!isInDocker()) tasks.push(loadRegistry(page))
await Promise.all(tasks)
}
// ===== 版本检测 =====
@@ -74,21 +77,39 @@ async function loadVersion(page) {
const sourceTag = isChinese ? '汉化优化版' : '官方原版'
const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版'
const switchTarget = isChinese ? 'official' : 'chinese'
bar.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
if (isInDocker()) {
bar.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">Docker 部署</span></span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest + '(请拉取新镜像更新)' : '已是最新版本'}</div>
${hasUpdate ? `<div style="margin-top:var(--space-sm)">
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
</div>` : ''}
</div>
</div>
</div>
`
`
} else {
bar.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
</div>
</div>
</div>
`
}
} catch (e) {
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">版本信息加载失败</div></div>`
}

View File

@@ -62,7 +62,7 @@ async function loadRoute() {
_contentEl.appendChild(spinnerEl)
try {
mod = await withTimeout(loader(), 15000, '模块加载超时')
mod = await retryLoad(loader, 3, 500)
} catch (e) {
console.error('[router] 模块加载失败:', hash, e)
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
@@ -106,6 +106,22 @@ async function loadRoute() {
})
}
async function retryLoad(loader, maxRetries, delayMs) {
for (let i = 0; i <= maxRetries; i++) {
try {
return await withTimeout(loader(), 15000, '模块加载超时')
} catch (e) {
const isNetworkError = /fetch|network|connection|ERR_/i.test(String(e?.message || e))
if (i < maxRetries && isNetworkError) {
console.warn(`[router] 模块加载失败,${delayMs}ms 后重试 (${i + 1}/${maxRetries})...`)
await new Promise(r => setTimeout(r, delayMs))
continue
}
throw e
}
}
}
function withTimeout(promise, ms, msg) {
return Promise.race([
promise,

View File

@@ -50,6 +50,105 @@
white-space: nowrap;
}
/* === Instance Switcher === */
.instance-switcher {
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--border-secondary);
position: relative;
}
.instance-current {
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: border-color 0.15s;
}
.instance-current:hover {
border-color: var(--accent);
}
.instance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.instance-dot.local { background: var(--accent); }
.instance-dot.remote { background: #f59e0b; }
.instance-dot.online { background: var(--success, #22c55e); }
.instance-dot.offline { background: var(--error, #ef4444); }
.instance-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instance-chevron {
flex-shrink: 0;
opacity: 0.5;
}
.instance-dropdown {
display: none;
position: absolute;
left: var(--space-sm);
right: var(--space-sm);
top: 100%;
z-index: 100;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: var(--space-xs) 0;
max-height: 260px;
overflow-y: auto;
}
.instance-dropdown.open { display: block; }
.instance-option {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 6px 12px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s;
}
.instance-option:hover { background: var(--bg-tertiary); }
.instance-option.active {
color: var(--accent);
font-weight: 600;
}
.instance-option.instance-add {
color: var(--text-tertiary);
font-size: var(--font-size-xs);
}
.instance-opt-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instance-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
flex-shrink: 0;
}
.instance-divider {
height: 1px;
background: var(--border-secondary);
margin: var(--space-xs) 0;
}
.sidebar-nav {
flex: 1;
padding: var(--space-sm) var(--space-sm);
@@ -209,6 +308,67 @@
background: var(--accent-muted);
}
/* 版本更新通知横幅 */
.update-banner {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
padding: 10px 20px;
font-size: var(--font-size-sm);
z-index: 100;
transition: all 300ms ease;
overflow: hidden;
max-height: 60px;
}
.update-banner-hidden {
max-height: 0;
padding: 0 20px;
opacity: 0;
}
.update-banner-content {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.update-banner-text {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 8px;
}
.update-banner-ver {
font-weight: 700;
font-size: var(--font-size-sm);
}
.update-banner-changelog {
font-size: var(--font-size-xs);
opacity: 0.85;
}
.update-banner .btn {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
font-weight: 600;
backdrop-filter: blur(4px);
}
.update-banner .btn:hover {
background: rgba(255,255,255,0.3);
}
.update-banner-close {
background: none;
border: none;
color: rgba(255,255,255,0.7);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
flex-shrink: 0;
}
.update-banner-close:hover {
color: #fff;
}
/* Gateway 未启动引导横幅 */
.gw-banner {
background: var(--warning, #f59e0b);

View File

@@ -824,3 +824,424 @@
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* === Docker 集群管理 === */
.docker-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.docker-section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
/* 节点网格 */
.docker-node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-md);
}
.docker-node-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition: all var(--transition-fast);
}
.docker-node-card:hover {
border-color: var(--border-focus);
}
.docker-node-card.offline {
opacity: 0.7;
}
.docker-node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.docker-node-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.docker-node-status.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,.4); }
.docker-node-status.offline { background: #ef4444; }
.docker-node-name {
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.docker-node-badge {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
}
.docker-node-card.online .docker-node-badge {
background: rgba(34,197,94,.1);
color: #22c55e;
}
.docker-node-remove {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 4px;
}
.docker-node-remove:hover { color: var(--error, #ef4444); }
.docker-node-info {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-bottom: var(--space-sm);
}
.docker-node-error { color: var(--error, #ef4444); }
.docker-node-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-sm);
border-top: 1px solid var(--border-secondary);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 容器表格 */
.docker-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
scrollbar-width: none;
}
.docker-table-wrap::-webkit-scrollbar { display: none; }
.docker-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.docker-table th {
text-align: left;
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 500;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid var(--border-primary);
}
.docker-table td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-secondary);
color: var(--text-primary);
vertical-align: middle;
}
.docker-table tr:last-child td { border-bottom: none; }
.docker-table tr:hover td { background: var(--bg-card-hover); }
.docker-ct-name {
font-weight: 600;
display: block;
}
.docker-ct-id {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--font-mono);
}
.docker-ct-image {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docker-ct-state {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.docker-ct-state.running {
background: rgba(34,197,94,.1);
color: #22c55e;
}
.docker-ct-state.stopped {
background: var(--bg-secondary);
color: var(--text-tertiary);
}
.docker-ct-ports {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.docker-ct-actions {
display: flex;
gap: 4px;
white-space: nowrap;
}
.btn-icon {
background: none;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
padding: 4px 6px;
cursor: pointer;
font-size: 13px;
line-height: 1;
transition: all var(--transition-fast);
}
.btn-icon:hover {
background: var(--bg-secondary);
border-color: var(--border-focus);
}
.btn-icon.danger:hover {
background: rgba(239,68,68,.1);
border-color: #ef4444;
}
.btn-icon:disabled { opacity: .4; pointer-events: none; }
/* 空状态 */
.docker-empty {
text-align: center;
padding: var(--space-2xl) var(--space-xl);
}
.docker-empty-icon {
color: var(--text-tertiary);
margin-bottom: var(--space-md);
}
.docker-empty-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.docker-empty-desc {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--space-lg);
}
.docker-empty-hint {
text-align: left;
display: inline-block;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--space-md) var(--space-lg);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.docker-empty-hint code {
display: block;
margin: 4px 0;
padding: 4px 8px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.docker-empty-inline {
text-align: center;
padding: var(--space-xl);
color: var(--text-tertiary);
font-size: var(--font-size-sm);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
/* 弹窗 */
.docker-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 150ms ease;
}
.docker-dialog {
background: #fff;
border-radius: var(--radius-xl, 16px);
padding: var(--space-xl, 24px);
width: 90%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0,0,0,.35);
}
[data-theme="dark"] .docker-dialog {
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border-primary);
}
.docker-dialog-wide {
max-width: 720px;
}
.docker-dialog-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-lg);
}
.docker-dialog-hint {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin: var(--space-md) 0;
line-height: 1.6;
}
.docker-dialog-hint code {
background: var(--bg-primary);
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
}
.docker-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-lg);
}
/* 部署模式切换 */
.deploy-mode-toggle {
display: flex;
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 2px;
gap: 2px;
}
.deploy-mode-btn {
padding: 3px 12px;
border: none;
background: transparent;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 150ms;
}
.deploy-mode-btn.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,.1);
}
.deploy-auto-summary {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: var(--space-md);
margin: var(--space-sm) 0;
}
.deploy-auto-title {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
margin-bottom: var(--space-sm);
text-transform: uppercase;
letter-spacing: .5px;
}
.deploy-auto-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 13px;
color: var(--text-secondary);
}
.deploy-auto-item + .deploy-auto-item {
border-top: 1px solid var(--border-primary);
}
.deploy-auto-item span:last-child {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
}
/* 节点端点预设按钮 */
.dn-presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dn-preset-btn {
padding: 5px 12px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-md, 8px);
cursor: pointer;
transition: all 150ms;
}
.dn-preset-btn:hover {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.dn-preset-btn.active {
background: var(--primary, #6366f1);
color: #fff;
border-color: var(--primary, #6366f1);
}
/* 其他容器折叠区 */
.docker-other-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-tertiary);
padding: var(--space-sm) 0;
list-style: none;
user-select: none;
}
.docker-other-toggle::-webkit-details-marker { display: none; }
.docker-other-toggle::before {
content: '▶';
font-size: 10px;
transition: transform 150ms;
}
details.docker-other-section[open] > .docker-other-toggle::before {
transform: rotate(90deg);
}
.docker-other-count {
background: var(--bg-secondary);
color: var(--text-tertiary);
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
font-weight: 600;
}
/* 日志 */
.docker-logs-content {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}