diff --git a/docs/docker-multi-instance-plan.md b/docs/docker-multi-instance-plan.md
new file mode 100644
index 0000000..0851aa0
--- /dev/null
+++ b/docs/docker-multi-instance-plan.md
@@ -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
+
diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index d454272..7a33489 100644
--- a/scripts/dev-api.js
+++ b/scripts/dev-api.js
@@ -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) {
diff --git a/src/components/sidebar.js b/src/components/sidebar.js
index 08af03f..f37a66c 100644
--- a/src/components/sidebar.js
+++ b/src/components/sidebar.js
@@ -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: '
',
security: '
',
skills: '
',
+ docker: '
',
debug: '
',
}
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 = `
+ ${showSwitcher ? `
+
+
+
` : ''}