From b904fb239867966f11fe4e6715ba230d26ad8812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 8 Mar 2026 13:44:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=E9=9B=86=E7=BE=A4=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=B9=E8=BF=9B=20-=20=E9=83=A8=E7=BD=B2=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=9F=BA=E7=A1=80/=E9=AB=98=E7=BA=A7=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E5=AE=B9=E5=99=A8=E5=88=86=E7=B1=BB=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E8=8A=82=E7=82=B9=E7=AB=AF=E7=82=B9=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E6=A3=80=E6=B5=8B=E3=80=81=E7=99=BB=E5=BD=95=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker-multi-instance-plan.md | 494 +++++++++++++++++++++++ docs/update/latest.json | 2 +- index.html | 1 + scripts/dev-api.js | 545 ++++++++++++++++++++++++- src/components/sidebar.js | 162 +++++++- src/lib/app-state.js | 41 ++ src/lib/tauri-api.js | 73 +++- src/main.js | 214 +++++++++- src/pages/about.js | 4 +- src/pages/docker.js | 618 +++++++++++++++++++++++++++++ src/pages/services.js | 61 ++- src/router.js | 18 +- src/style/layout.css | 160 ++++++++ src/style/pages.css | 421 ++++++++++++++++++++ vite.config.js | 6 + 15 files changed, 2778 insertions(+), 42 deletions(-) create mode 100644 docs/docker-multi-instance-plan.md create mode 100644 src/pages/docker.js 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 +
+ +
+
+ 本机 +
+
+ openclaw-prod + Docker +
+
+
+ + 添加实例 +
+
+
+``` + +### 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: 前端实例管理 API(tauri-api.js) +- 新增 `api.instance*` 方法 + mock 数据 +- 预计:~40 行 + +### Step 4: 前端状态管理(app-state.js) +- `_activeInstance` 状态 + `switchInstance()` 函数 +- 预计:~50 行 + +### Step 5: 实例切换器 UI(sidebar.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 前提条件 +- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0 +- Docker 实例使用 full 镜像(含 Panel + Gateway) +- 网络可达(ClawPanel 后端能访问远程实例的端口) + +### 7.3 兼容性 +- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变 +- 实例切换器在只有本机时可以隐藏或最小化显示 +- 所有新功能向后兼容 + +--- + +## 8. 测试计划 + +| 场景 | 验证内容 | +|------|---------| +| 纯本机使用 | 现有功能不受影响,无回归 | +| 部署 Docker 容器 | 自动注册为可管理实例 | +| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 | +| 切换实例后聊天 | WebSocket 连接到正确的 Gateway | +| 远程实例离线 | 优雅报错,可切回本机 | +| 删除 Docker 容器 | 实例列表自动移除 | +| 多实例批量健康检查 | 侧边栏状态点实时更新 | diff --git a/docs/update/latest.json b/docs/update/latest.json index d6708c4..6f60aa8 100644 --- a/docs/update/latest.json +++ b/docs/update/latest.json @@ -1,5 +1,5 @@ { - "version": "0.6.0", + "version": "0.7.0", "minAppVersion": "0.6.0", "hash": "", "url": "", diff --git a/index.html b/index.html index 44e2709..207d457 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,7 @@
+
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 ? `
+ +
+
` : ''}