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

@@ -0,0 +1,494 @@
# ClawPanel Docker 多实例管理 — 技术规划
> 版本: v1.0 | 日期: 2026-03-08
## 1. 问题分析
### 1.1 现状
ClawPanel 当前架构是 **单实例管理**
```
浏览器 → ClawPanel 前端
├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件
├── /ws → 代理到本机 Gateway:18789 (WebSocket)
└── 静态文件 → dist/
```
**所有页面**模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是:
- 本机文件系统上的 `~/.openclaw/openclaw.json`
- 本机运行的 Gateway 进程(端口 18789
### 1.2 Phase 1 已完成
Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API
- 启动/停止/重启/删除容器
- 部署新容器(端口映射、数据卷、环境变量)
- 查看容器日志
- 多节点管理(本机 + 远程 Docker 主机)
### 1.3 缺口
Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**
- 无法配置某个容器内的模型
- 无法查看某个容器内的 Gateway 日志
- 无法管理某个容器内的 Agent
- 聊天功能只连本机 Gateway
---
## 2. 目标架构
### 2.1 核心思路API 代理 + 实例切换
```
┌──────────────────────────────────────────────────┐
│ ClawPanel 前端 │
│ ┌────────────────────────────────────────────┐ │
│ │ 实例切换器: [ ● 本机 ▼ ] │ │
│ │ [ ○ prod-server (Docker) ] │ │
│ │ [ ○ dev-box (远程) ] │ │
│ │ [ + 添加实例 ] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 现有页面(模型/Agent/Gateway/日志/聊天...
│ │ │
│ │ api.readOpenclawConfig() │
│ │ api.listAgents() │
│ ▼ │
│ tauri-api.js → webInvoke('read_openclaw_config') │
│ │ │
│ 自动附带 instanceId │
└──────────────────┼───────────────────────────────┘
dev-api.js (本机后端)
┌────────┼────────┐
▼ ▼ ▼
本机文件 代理转发 代理转发
~/.openclaw ↓ ↓
实例 A 实例 B
http://host http://192.168.1.100
:18790 :1420
/__api/* /__api/*
```
**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。**
因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。
### 2.2 WebSocket 连接
```
切换实例时:
wsClient.disconnect() ← 断开旧连接
wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway
```
WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。
### 2.3 自动组网流程
部署新容器时自动完成:
```
用户点击「部署容器」
├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789
├─ 2. 启动容器,等待健康检查通过
├─ 3. 探测容器 Panel 端点GET http://hostIP:hostPort/__api/check_installation
├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json
└─ 5. 前端自动刷新实例列表
```
---
## 3. 数据结构
### 3.1 实例注册表
文件位置:`~/.openclaw/instances.json`
```json
{
"activeId": "local",
"instances": [
{
"id": "local",
"name": "本机",
"type": "local",
"endpoint": null,
"gatewayPort": 18789,
"addedAt": 1741420800,
"note": ""
},
{
"id": "docker-abc123",
"name": "openclaw-prod",
"type": "docker",
"endpoint": "http://127.0.0.1:18790",
"gatewayPort": 18789,
"containerId": "abc123def456",
"nodeId": "local",
"addedAt": 1741420900,
"note": "生产环境"
},
{
"id": "remote-1",
"name": "办公室服务器",
"type": "remote",
"endpoint": "http://192.168.1.100:1420",
"gatewayPort": 18789,
"addedAt": 1741421000,
"note": ""
}
]
}
```
**三种实例类型:**
| type | 说明 | 来源 |
|------|------|------|
| `local` | 本机 OpenClaw | 始终存在,不可删除 |
| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 |
| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 |
### 3.2 实例状态(运行时,不持久化)
```js
{
id: 'docker-abc123',
online: true, // 健康检查结果
version: '2026.3.5', // OpenClaw 版本
gatewayRunning: true, // Gateway 状态
lastCheck: 1741420999, // 上次检查时间
}
```
---
## 4. 改动清单
### 4.1 后端 dev-api.js
#### 4.1.1 实例注册表管理(新增)
```
新增 handlers:
instance_list → 读取 instances.json
instance_add → 添加实例(手动或自动)
instance_remove → 删除实例
instance_set_active → 切换活跃实例
instance_health_check → 健康检查单个实例
instance_health_all → 批量健康检查
```
#### 4.1.2 API 代理转发(核心改动)
改造 `_apiMiddleware`
```js
// 伪代码
async function _apiMiddleware(req, res, next) {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = extractCmd(req.url)
const body = await readBody(req)
// 实例管理命令 → 始终本机处理
if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) {
return handleLocally(cmd, body, res)
}
// 获取当前活跃实例
const active = getActiveInstance()
if (active.type === 'local') {
// 本机 → 直接处理(现有逻辑不变)
return handleLocally(cmd, body, res)
}
// 远程/Docker 实例 → 代理转发
return proxyToInstance(active, cmd, body, res)
}
```
**始终在本机处理的命令ALWAYS_LOCAL**
- `instance_*` — 实例管理本身
- `docker_*` — Docker 容器管理
- `auth_*` — 认证
- `read_panel_config` / `write_panel_config` — 本地面板配置
- `assistant_*` — AI 助手(操作本机文件系统)
**通过代理转发的命令:**
- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置
- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务
- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent
- `read_log_tail` / `search_log` — 目标实例的日志
- `get_version_info` / `upgrade_openclaw` — 目标实例的版本
- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件
- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置
- 等其他 OpenClaw 相关命令
#### 4.1.3 代理转发实现
```js
async function proxyToInstance(instance, cmd, body, res) {
const url = `${instance.endpoint}/__api/${cmd}`
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await resp.text()
res.writeHead(resp.status, { 'Content-Type': 'application/json' })
res.end(data)
} catch (e) {
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` }))
}
}
```
#### 4.1.4 Docker 部署自动注册
修改 `docker_create_container` handler
- 容器创建并启动后,自动等待健康检查
- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证
- 健康检查通过后自动写入 `instances.json`
- 返回结果包含 `instanceId`
### 4.2 前端 tauri-api.js
#### 4.2.1 新增实例管理 API
```js
// 实例管理
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'),
```
**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。
#### 4.2.2 无需改动的部分
现有的 `api.readOpenclawConfig()``api.listAgents()` 等方法 **完全不变**
代理逻辑在后端 `_apiMiddleware` 层透明处理。
### 4.3 前端 app-state.js
新增:
```js
let _activeInstance = { id: 'local', name: '本机', type: 'local' }
let _instanceListeners = []
export function getActiveInstance() { return _activeInstance }
export function onInstanceChange(fn) { ... }
export async function switchInstance(id) {
// 1. 调后端切换
await api.instanceSetActive(id)
// 2. 更新本地状态
_activeInstance = instances.find(i => i.id === id)
// 3. 清缓存
invalidate() // 清 API 缓存
// 4. 断开旧 WebSocket
wsClient.disconnect()
// 5. 重新检测状态
await detectOpenclawStatus()
// 6. 连接新实例的 Gateway WebSocket
connectToActiveGateway()
// 7. 通知所有监听者(侧边栏、页面刷新)
_instanceListeners.forEach(fn => fn(_activeInstance))
}
```
### 4.4 前端 sidebar.js
在侧边栏顶部 logo 下方添加实例切换器:
```html
<div class="instance-switcher">
<button class="instance-current" onclick="toggleDropdown()">
<span class="instance-dot online"></span>
<span class="instance-name">本机</span>
<svg class="chevron"></svg>
</button>
<div class="instance-dropdown">
<div class="instance-option active" data-id="local">
<span class="instance-dot online"></span> 本机
</div>
<div class="instance-option" data-id="docker-abc123">
<span class="instance-dot online"></span> openclaw-prod
<span class="instance-badge">Docker</span>
</div>
<hr/>
<div class="instance-option" onclick="addInstance()">
<span>+ 添加实例</span>
</div>
</div>
</div>
```
### 4.5 前端 main.js
`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点:
```js
async function autoConnectWebSocket() {
const instance = getActiveInstance()
if (instance.type === 'local') {
// 本机:读本地配置
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
wsClient.connect(`127.0.0.1:${port}`, token)
} else {
// 远程/Docker从实例 endpoint 推导 Gateway 地址
const config = await api.readOpenclawConfig() // 已通过代理转发
const gwPort = config?.gateway?.port || 18789
const url = new URL(instance.endpoint)
wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token)
}
}
```
### 4.6 serve.js WebSocket 代理
WebSocket 代理改为动态目标:
```js
server.on('upgrade', (req, socket, head) => {
// 从 query 或 header 中获取目标实例
const target = resolveWsTarget(req)
const conn = net.createConnection(target.port, target.host, () => { ... })
})
```
### 4.7 docker.js 集群页面
部署对话框增加"自动注册"逻辑:
- 容器创建成功后显示"正在等待实例就绪..."
- 健康检查通过后自动出现在实例切换器中
- 用户可直接切换到新实例进行管理
### 4.8 现有页面适配
| 页面 | 改动 | 说明 |
|------|------|------|
| dashboard.js | 极小 | 页头显示当前实例名称 |
| models.js | 无 | API 透明代理 |
| agents.js | 无 | API 透明代理 |
| gateway.js | 极小 | 远程实例时隐藏部分本机功能 |
| logs.js | 无 | API 透明代理 |
| chat.js | 无 | WebSocket 已切换到目标实例 |
| chat-debug.js | 无 | API 透明代理 |
| memory.js | 无 | API 透明代理 |
| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 |
| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 |
| skills.js | 无 | API 透明代理 |
| security.js | 小 | 远程实例的密码管理走代理 |
| setup.js | 小 | 远程实例不需要 setup 流程 |
| assistant.js | 特殊 | AI 助手始终操作本机ALWAYS_LOCAL |
---
## 5. 实施步骤
### Step 1: 实例注册表后端dev-api.js
- `readInstances()` / `saveInstances()` 工具函数
- 6 个 handler`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all`
- 预计:~150 行
### Step 2: API 代理转发dev-api.js
- 改造 `_apiMiddleware` 添加代理逻辑
- `proxyToInstance()` 函数
- `ALWAYS_LOCAL` 命令集合
- 预计:~80 行
### Step 3: 前端实例管理 APItauri-api.js
- 新增 `api.instance*` 方法 + mock 数据
- 预计:~40 行
### Step 4: 前端状态管理app-state.js
- `_activeInstance` 状态 + `switchInstance()` 函数
- 预计:~50 行
### Step 5: 实例切换器 UIsidebar.js
- 下拉选择器组件 + CSS
- 预计:~100 行 JS + ~80 行 CSS
### Step 6: WebSocket 动态连接main.js + serve.js
- 切换实例时重新连接 WebSocket
- serve.js WebSocket 代理动态化
- 预计:~40 行
### Step 7: Docker 部署自动注册docker.js + dev-api.js
- `docker_create_container` 完成后自动注册
- 健康检查 + 就绪等待
- 预计:~60 行
### Step 8: 页面微调
- dashboard 显示实例名
- 远程实例时隐藏本机独占功能
- 预计:~30 行
**总计新增代码:约 600 行**
---
## 6. 安全考虑
### 6.1 认证
- 远程实例可能有不同的访问密码
- 代理转发时需要携带目标实例的认证凭据
- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定)
### 6.2 网络安全
- Docker 容器默认只暴露在宿主机网络
- 远程实例建议通过 SSH 隧道或 VPN 连接
- 不建议在公网暴露 `/__api/` 端点而不加密码
### 6.3 权限隔离
- AI 助手assistant_*)始终操作本机文件系统,不代理到远程
- Docker 管理docker_*)始终操作本机 Docker不代理
---
## 7. 边界与约束
### 7.1 不做的事情
- **不做** 统一聚合视图(如"查看所有实例的模型列表"
- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做
- **不做** 实例间负载均衡
- **不做** 复杂的权限角色系统
### 7.2 前提条件
- 远程实例必须运行 ClawPanelserve.js版本 >= 0.7.0
- Docker 实例使用 full 镜像(含 Panel + Gateway
- 网络可达ClawPanel 后端能访问远程实例的端口)
### 7.3 兼容性
- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变
- 实例切换器在只有本机时可以隐藏或最小化显示
- 所有新功能向后兼容
---
## 8. 测试计划
| 场景 | 验证内容 |
|------|---------|
| 纯本机使用 | 现有功能不受影响,无回归 |
| 部署 Docker 容器 | 自动注册为可管理实例 |
| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 |
| 切换实例后聊天 | WebSocket 连接到正确的 Gateway |
| 远程实例离线 | 优雅报错,可切回本机 |
| 删除 Docker 容器 | 实例列表自动移除 |
| 多实例批量健康检查 | 侧边栏状态点实时更新 |

View File

@@ -1,5 +1,5 @@
{
"version": "0.6.0",
"version": "0.7.0",
"minAppVersion": "0.6.0",
"hash": "",
"url": "",

View File

@@ -71,6 +71,7 @@
<div id="app">
<aside id="sidebar"></aside>
<div id="main-col">
<div id="update-banner" class="update-banner update-banner-hidden"></div>
<div id="gw-banner" class="gw-banner gw-banner-hidden"></div>
<main id="content"></main>
</div>

View File

@@ -8,7 +8,9 @@ import path from 'path'
import os from 'os'
import { homedir, networkInterfaces } from 'os'
import { execSync, spawn } from 'child_process'
import { fileURLToPath } from 'url'
import net from 'net'
import http from 'http'
import crypto from 'crypto'
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
@@ -24,6 +26,28 @@ const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write']
const PANEL_CONFIG_PATH = path.join(OPENCLAW_DIR, 'clawpanel.json')
const DOCKER_NODES_PATH = path.join(OPENCLAW_DIR, 'docker-nodes.json')
const INSTANCES_PATH = path.join(OPENCLAW_DIR, 'instances.json')
const DOCKER_SOCKET = process.platform === 'win32' ? '//./pipe/docker_engine' : '/var/run/docker.sock'
const OPENCLAW_IMAGE = 'ghcr.io/qingchencloud/openclaw'
// 语义化版本比较
function versionGe(a, b) {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return true
if ((pa[i] || 0) < (pb[i] || 0)) return false
}
return true
}
function versionGt(a, b) {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return true
if ((pa[i] || 0) < (pb[i] || 0)) return false
}
return false
}
// === 访问密码 & Session 管理 ===
@@ -483,6 +507,159 @@ function linuxStopGateway() {
}
}
// === Docker Socket 通信 ===
function dockerRequest(method, apiPath, body = null, endpoint = null) {
return new Promise((resolve, reject) => {
const opts = { path: apiPath, method, headers: { 'Content-Type': 'application/json' } }
if (endpoint && endpoint.startsWith('tcp://')) {
const url = new URL(endpoint.replace('tcp://', 'http://'))
opts.hostname = url.hostname
opts.port = parseInt(url.port) || 2375
} else {
opts.socketPath = endpoint || DOCKER_SOCKET
}
const req = http.request(opts, (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(data) }) }
catch { resolve({ status: res.statusCode, data }) }
})
})
req.on('error', (e) => reject(new Error('Docker 连接失败: ' + e.message)))
req.setTimeout(30000, () => { req.destroy(); reject(new Error('Docker API 超时')) })
if (body) req.write(JSON.stringify(body))
req.end()
})
}
function readDockerNodes() {
if (!fs.existsSync(DOCKER_NODES_PATH)) {
return [{ id: 'local', name: '本机', type: 'socket', endpoint: DOCKER_SOCKET }]
}
try {
const data = JSON.parse(fs.readFileSync(DOCKER_NODES_PATH, 'utf8'))
return data.nodes || []
} catch {
return [{ id: 'local', name: '本机', type: 'socket', endpoint: DOCKER_SOCKET }]
}
}
function saveDockerNodes(nodes) {
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
fs.writeFileSync(DOCKER_NODES_PATH, JSON.stringify({ nodes }, null, 2))
}
function isDockerAvailable() {
if (isWindows) return true // named pipe, can't stat
return fs.existsSync(DOCKER_SOCKET)
}
// === 实例注册表 ===
const DEFAULT_LOCAL_INSTANCE = { id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }
function readInstances() {
if (!fs.existsSync(INSTANCES_PATH)) {
return { activeId: 'local', instances: [{ ...DEFAULT_LOCAL_INSTANCE }] }
}
try {
const data = JSON.parse(fs.readFileSync(INSTANCES_PATH, 'utf8'))
if (!data.instances?.length) data.instances = [{ ...DEFAULT_LOCAL_INSTANCE }]
if (!data.instances.find(i => i.id === 'local')) data.instances.unshift({ ...DEFAULT_LOCAL_INSTANCE })
if (!data.activeId || !data.instances.find(i => i.id === data.activeId)) data.activeId = 'local'
return data
} catch {
return { activeId: 'local', instances: [{ ...DEFAULT_LOCAL_INSTANCE }] }
}
}
function saveInstances(data) {
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
fs.writeFileSync(INSTANCES_PATH, JSON.stringify(data, null, 2))
}
function getActiveInstance() {
const data = readInstances()
return data.instances.find(i => i.id === data.activeId) || data.instances[0]
}
async function proxyToInstance(instance, cmd, body) {
const url = `${instance.endpoint}/__api/${cmd}`
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const text = await resp.text()
try { return JSON.parse(text) }
catch { return text }
}
async function instanceHealthCheck(instance) {
const result = { id: instance.id, online: false, version: null, gatewayRunning: false, lastCheck: Date.now() }
if (instance.type === 'local') {
result.online = true
try {
const services = await handlers.get_services_status()
result.gatewayRunning = services?.[0]?.running === true
} catch {}
try {
const ver = await handlers.get_version_info()
result.version = ver?.current
} catch {}
return result
}
if (!instance.endpoint) return result
try {
const resp = await fetch(`${instance.endpoint}/__api/check_installation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
signal: AbortSignal.timeout(5000),
})
if (resp.ok) {
const data = await resp.json()
result.online = true
result.version = data?.version || null
}
} catch {}
if (result.online) {
try {
const resp = await fetch(`${instance.endpoint}/__api/get_services_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
signal: AbortSignal.timeout(5000),
})
if (resp.ok) {
const services = await resp.json()
result.gatewayRunning = services?.[0]?.running === true
}
} catch {}
}
return result
}
// 始终在本机处理的命令(不代理到远程实例)
const ALWAYS_LOCAL = new Set([
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
'instance_health_check', 'instance_health_all',
'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',
'auth_check', 'auth_login', 'auth_logout',
'read_panel_config', 'write_panel_config',
'get_deploy_mode',
'assistant_exec', 'assistant_read_file', 'assistant_write_file',
'assistant_list_dir', 'assistant_system_info', 'assistant_list_processes',
'assistant_check_port', 'assistant_web_search', 'assistant_fetch_url',
'assistant_ensure_data_dir', 'assistant_save_image', 'assistant_load_image', 'assistant_delete_image',
])
// === API Handlers ===
const handlers = {
@@ -591,9 +768,331 @@ const handlers = {
}
},
// === 实例管理 ===
instance_list() {
const data = readInstances()
return data
},
instance_add({ name, type, endpoint, gatewayPort, containerId, nodeId, note }) {
if (!name) throw new Error('实例名称不能为空')
if (!endpoint) throw new Error('端点地址不能为空')
const data = readInstances()
const id = type === 'docker' ? `docker-${(containerId || Date.now().toString(36)).slice(0, 12)}` : `remote-${Date.now().toString(36)}`
if (data.instances.find(i => i.endpoint === endpoint)) throw new Error('该端点已存在')
data.instances.push({
id, name, type: type || 'remote', endpoint,
gatewayPort: gatewayPort || 18789,
containerId: containerId || null,
nodeId: nodeId || null,
addedAt: Math.floor(Date.now() / 1000),
note: note || '',
})
saveInstances(data)
return { id, name }
},
instance_remove({ id }) {
if (id === 'local') throw new Error('本机实例不可删除')
const data = readInstances()
data.instances = data.instances.filter(i => i.id !== id)
if (data.activeId === id) data.activeId = 'local'
saveInstances(data)
return true
},
instance_set_active({ id }) {
const data = readInstances()
if (!data.instances.find(i => i.id === id)) throw new Error('实例不存在')
data.activeId = id
saveInstances(data)
return { activeId: id }
},
async instance_health_check({ id }) {
const data = readInstances()
const instance = data.instances.find(i => i.id === id)
if (!instance) throw new Error('实例不存在')
return instanceHealthCheck(instance)
},
async instance_health_all() {
const data = readInstances()
const results = await Promise.allSettled(data.instances.map(i => instanceHealthCheck(i)))
return results.map((r, idx) => r.status === 'fulfilled' ? r.value : { id: data.instances[idx].id, online: false, lastCheck: Date.now() })
},
// === Docker 集群管理 ===
async docker_test_endpoint({ endpoint } = {}) {
if (!endpoint) throw new Error('请提供端点地址')
const resp = await dockerRequest('GET', '/info', null, endpoint)
if (resp.status !== 200) throw new Error('Docker 守护进程未响应')
const d = resp.data
return {
ServerVersion: d.ServerVersion,
Containers: d.Containers,
Images: d.Images,
OS: d.OperatingSystem,
}
},
async docker_info({ nodeId } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('GET', '/info', null, node.endpoint)
if (resp.status !== 200) throw new Error('Docker 守护进程未响应')
const d = resp.data
return {
nodeId: node.id, nodeName: node.name,
containers: d.Containers, containersRunning: d.ContainersRunning,
containersPaused: d.ContainersPaused, containersStopped: d.ContainersStopped,
images: d.Images, serverVersion: d.ServerVersion,
os: d.OperatingSystem, arch: d.Architecture,
cpus: d.NCPU, memory: d.MemTotal,
}
},
async docker_list_containers({ nodeId, all = true } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const query = all ? '?all=true' : ''
const resp = await dockerRequest('GET', `/containers/json${query}`, null, node.endpoint)
if (resp.status !== 200) throw new Error('获取容器列表失败')
return (resp.data || []).map(c => ({
id: c.Id?.slice(0, 12),
name: (c.Names?.[0] || '').replace(/^\//, ''),
image: c.Image,
state: c.State,
status: c.Status,
ports: (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : `${p.PrivatePort}`).join(', '),
created: c.Created,
nodeId: node.id, nodeName: node.name,
}))
},
async docker_create_container({ nodeId, name, image, tag = 'latest', panelPort = 1420, gatewayPort = 18789, envVars = {}, volume = true } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const imgFull = `${image || OPENCLAW_IMAGE}:${tag}`
const containerName = name || `openclaw-${Date.now().toString(36)}`
const env = Object.entries(envVars).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`)
const portBindings = {}
const exposedPorts = {}
if (panelPort) {
portBindings['1420/tcp'] = [{ HostPort: String(panelPort) }]
exposedPorts['1420/tcp'] = {}
}
if (gatewayPort) {
portBindings['18789/tcp'] = [{ HostPort: String(gatewayPort) }]
exposedPorts['18789/tcp'] = {}
}
const config = {
Image: imgFull,
Env: env,
ExposedPorts: exposedPorts,
HostConfig: {
PortBindings: portBindings,
RestartPolicy: { Name: 'unless-stopped' },
Binds: volume ? [`openclaw-data-${containerName}:/root/.openclaw`] : [],
},
}
const query = `?name=${encodeURIComponent(containerName)}`
const resp = await dockerRequest('POST', `/containers/create${query}`, config, node.endpoint)
if (resp.status === 404) {
// Image not found, need to pull first
throw new Error(`镜像 ${imgFull} 不存在,请先拉取`)
}
if (resp.status !== 201) throw new Error(resp.data?.message || '创建容器失败')
// Auto-start
const startResp = await dockerRequest('POST', `/containers/${resp.data.Id}/start`, null, node.endpoint)
if (startResp.status !== 204 && startResp.status !== 304) {
throw new Error('容器已创建但启动失败')
}
const containerId = resp.data.Id?.slice(0, 12)
// 自动注册为可管理实例
if (panelPort) {
const endpoint = `http://127.0.0.1:${panelPort}`
const instData = readInstances()
if (!instData.instances.find(i => i.endpoint === endpoint)) {
instData.instances.push({
id: `docker-${containerId}`,
name: containerName,
type: 'docker',
endpoint,
gatewayPort: gatewayPort || 18789,
containerId,
nodeId: node.id,
addedAt: Math.floor(Date.now() / 1000),
note: `Image: ${imgFull}`,
})
saveInstances(instData)
}
}
return { id: containerId, name: containerName, started: true, instanceId: `docker-${containerId}` }
},
async docker_start_container({ nodeId, containerId } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('POST', `/containers/${containerId}/start`, null, node.endpoint)
if (resp.status !== 204 && resp.status !== 304) throw new Error(resp.data?.message || '启动失败')
return true
},
async docker_stop_container({ nodeId, containerId } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('POST', `/containers/${containerId}/stop`, null, node.endpoint)
if (resp.status !== 204 && resp.status !== 304) throw new Error(resp.data?.message || '停止失败')
return true
},
async docker_restart_container({ nodeId, containerId } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('POST', `/containers/${containerId}/restart`, null, node.endpoint)
if (resp.status !== 204) throw new Error(resp.data?.message || '重启失败')
return true
},
async docker_remove_container({ nodeId, containerId, force = false } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const query = force ? '?force=true&v=true' : '?v=true'
const resp = await dockerRequest('DELETE', `/containers/${containerId}${query}`, null, node.endpoint)
if (resp.status !== 204) throw new Error(resp.data?.message || '删除失败')
// 自动移除对应的实例注册
const instData = readInstances()
const instId = `docker-${containerId}`
const before = instData.instances.length
instData.instances = instData.instances.filter(i => i.id !== instId && i.containerId !== containerId)
if (instData.instances.length < before) {
if (instData.activeId === instId) instData.activeId = 'local'
saveInstances(instData)
}
return true
},
async docker_container_logs({ nodeId, containerId, tail = 200 } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('GET', `/containers/${containerId}/logs?stdout=true&stderr=true&tail=${tail}`, null, node.endpoint)
// Docker logs 返回带 stream header 的原始字节,简单清理
let logs = typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data)
// 去除 Docker stream 帧头(每 8 字节一个 header
logs = logs.replace(/[\x00-\x08]/g, '').replace(/\r/g, '')
return logs
},
async docker_pull_image({ nodeId, image, tag = 'latest' } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const imgFull = `${image || OPENCLAW_IMAGE}:${tag}`
const resp = await dockerRequest('POST', `/images/create?fromImage=${encodeURIComponent(image || OPENCLAW_IMAGE)}&tag=${tag}`, null, node.endpoint)
if (resp.status !== 200) throw new Error(resp.data?.message || '拉取镜像失败')
return `镜像 ${imgFull} 拉取完成`
},
async docker_list_images({ nodeId } = {}) {
const nodes = readDockerNodes()
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
if (!node) throw new Error('节点不存在')
const resp = await dockerRequest('GET', '/images/json', null, node.endpoint)
if (resp.status !== 200) throw new Error('获取镜像列表失败')
return (resp.data || [])
.filter(img => (img.RepoTags || []).some(t => t.includes('openclaw')))
.map(img => ({
id: img.Id?.replace('sha256:', '').slice(0, 12),
tags: img.RepoTags || [],
size: img.Size,
created: img.Created,
}))
},
// Docker 节点管理
docker_list_nodes() {
return readDockerNodes()
},
async docker_add_node({ name, endpoint }) {
if (!name || !endpoint) throw new Error('节点名称和地址不能为空')
// 验证连接
try {
await dockerRequest('GET', '/info', null, endpoint)
} catch (e) {
throw new Error(`无法连接到 ${endpoint}: ${e.message}`)
}
const nodes = readDockerNodes()
const id = 'node-' + Date.now().toString(36)
const type = endpoint.startsWith('tcp://') ? 'tcp' : 'socket'
nodes.push({ id, name, type, endpoint })
saveDockerNodes(nodes)
return { id, name, type, endpoint }
},
docker_remove_node({ nodeId }) {
if (nodeId === 'local') throw new Error('不能删除本机节点')
const nodes = readDockerNodes().filter(n => n.id !== nodeId)
saveDockerNodes(nodes)
return true
},
// 集群概览(聚合所有节点)
async docker_cluster_overview() {
const nodes = readDockerNodes()
const results = []
for (const node of nodes) {
try {
const infoResp = await dockerRequest('GET', '/info', null, node.endpoint)
const ctResp = await dockerRequest('GET', '/containers/json?all=true', null, node.endpoint)
const containers = (ctResp.data || []).map(c => ({
id: c.Id?.slice(0, 12),
name: (c.Names?.[0] || '').replace(/^\//, ''),
image: c.Image, state: c.State, status: c.Status,
ports: (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : `${p.PrivatePort}`).join(', '),
}))
const d = infoResp.data || {}
results.push({
...node, online: true,
dockerVersion: d.ServerVersion, os: d.OperatingSystem,
cpus: d.NCPU, memory: d.MemTotal,
totalContainers: d.Containers, runningContainers: d.ContainersRunning,
stoppedContainers: d.ContainersStopped,
containers,
})
} catch (e) {
results.push({ ...node, online: false, error: e.message, containers: [] })
}
}
return results
},
// 部署模式检测
get_deploy_mode() {
const inDocker = fs.existsSync('/.dockerenv') || (process.env.CLAWPANEL_MODE === 'docker')
const dockerAvailable = isDockerAvailable()
return { inDocker, dockerAvailable, mode: inDocker ? 'docker' : 'local' }
},
// 安装检测
check_installation() {
return { installed: fs.existsSync(CONFIG_PATH), path: OPENCLAW_DIR, platform: isMac ? 'macos' : process.platform }
const inDocker = fs.existsSync('/.dockerenv')
return { installed: fs.existsSync(CONFIG_PATH), path: OPENCLAW_DIR, platform: isMac ? 'macos' : process.platform, inDocker }
},
check_node() {
@@ -1603,12 +2102,34 @@ const handlers = {
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
// 前端热更新
check_frontend_update() {
return { currentVersion: '0.6.0', latestVersion: '0.6.0', hasUpdate: false, compatible: true, updateReady: false, manifest: { version: '0.6.0', minAppVersion: '0.6.0' } }
async check_frontend_update() {
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
const currentVersion = pkg.version
try {
const resp = await globalThis.fetch('https://claw.qt.cool/update/latest.json', {
signal: AbortSignal.timeout(8000),
headers: { 'User-Agent': 'ClawPanel-Web' },
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const manifest = await resp.json()
const latestVersion = manifest.version || ''
const minAppVersion = manifest.minAppVersion || '0.0.0'
const compatible = versionGe(currentVersion, minAppVersion)
const hasUpdate = !!latestVersion && latestVersion !== currentVersion && compatible && versionGt(latestVersion, currentVersion)
return { currentVersion, latestVersion, hasUpdate, compatible, updateReady: false, manifest }
} catch {
return { currentVersion, latestVersion: currentVersion, hasUpdate: false, compatible: true, updateReady: false, manifest: { version: currentVersion } }
}
},
download_frontend_update() { return { success: true, files: 12, path: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
rollback_frontend_update() { return { success: true } },
get_update_status() { return { currentVersion: '0.6.0', updateReady: false, updateVersion: '', updateDir: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
get_update_status() {
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
return { currentVersion: pkg.version, updateReady: false, updateVersion: '', updateDir: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') }
},
write_env_file({ path: p, config }) {
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
if (!expanded.startsWith(OPENCLAW_DIR)) throw new Error('只允许写入 ~/.openclaw/ 下的文件')
@@ -1811,6 +2332,22 @@ async function _apiMiddleware(req, res, next) {
return
}
// --- 实例代理:非 ALWAYS_LOCAL 命令,活跃实例非本机时代理转发 ---
const activeInst = getActiveInstance()
if (activeInst.type !== 'local' && activeInst.endpoint && !ALWAYS_LOCAL.has(cmd)) {
try {
const args = await readBody(req)
const result = await proxyToInstance(activeInst, cmd, args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 502
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `实例「${activeInst.name}」不可达: ${e.message}` }))
}
return
}
const handler = handlers[cmd]
if (!handler) {

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;
}

View File

@@ -4,6 +4,9 @@ import fs from 'fs'
import path from 'path'
import { homedir } from 'os'
// 读取 package.json 版本号,构建时注入前端
const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
// 读取 Gateway 端口(启动时读取一次)
let gatewayPort = 18789
try {
@@ -13,6 +16,9 @@ try {
export default defineConfig({
plugins: [devApiPlugin()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
clearScreen: false,
server: {
port: 1420,