mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 10:00:04 +08:00
feat: Docker集群管理改进 - 部署弹窗基础/高级模式、容器分类管理、节点端点预设检测、登录安全增强
This commit is contained in:
@@ -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, '<').replace(/>/g, '>') }
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '' }),
|
||||
|
||||
214
src/main.js
214
src/main.js
@@ -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 session(WEB_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 () => {
|
||||
|
||||
@@ -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
618
src/pages/docker.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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="移除节点">×</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
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user