mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-28 03:01:54 +08:00
feat: Docker集群管理改进 - 部署弹窗基础/高级模式、容器分类管理、节点端点预设检测、登录安全增强
This commit is contained in:
494
docs/docker-multi-instance-plan.md
Normal file
494
docs/docker-multi-instance-plan.md
Normal 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: 前端实例管理 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 容器 | 实例列表自动移除 |
|
||||
| 多实例批量健康检查 | 侧边栏状态点实时更新 |
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"minAppVersion": "0.6.0",
|
||||
"hash": "",
|
||||
"url": "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
import { navigate, getCurrentRoute } from '../router.js'
|
||||
import { toggleTheme, getTheme } from '../lib/theme.js'
|
||||
import { isOpenclawReady } from '../lib/app-state.js'
|
||||
import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange } from '../lib/app-state.js'
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../../package.json'
|
||||
|
||||
const NAV_ITEMS_FULL = [
|
||||
@@ -39,6 +40,12 @@ const NAV_ITEMS_FULL = [
|
||||
{ route: '/skills', label: 'Skills', icon: 'skills' },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Docker',
|
||||
items: [
|
||||
{ route: '/docker', label: 'Docker 集群', icon: 'docker' },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
@@ -87,14 +94,31 @@ const ICONS = {
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
|
||||
docker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="11" width="4" height="3" rx=".5"/><rect x="6" y="11" width="4" height="3" rx=".5"/><rect x="11" y="11" width="4" height="3" rx=".5"/><rect x="6" y="7" width="4" height="3" rx=".5"/><rect x="11" y="7" width="4" height="3" rx=".5"/><rect x="16" y="11" width="4" height="3" rx=".5"/><rect x="11" y="3" width="4" height="3" rx=".5"/><path d="M2 17c1 3 4 5 10 5s9-2 10-5"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
let _delegated = false
|
||||
let _hasMultipleInstances = false
|
||||
|
||||
// 异步检测是否有多实例(首次渲染后触发,有多实例时重渲染)
|
||||
function _checkMultiInstances(el) {
|
||||
api.instanceList().then(data => {
|
||||
const has = data.instances && data.instances.length > 1
|
||||
if (has !== _hasMultipleInstances) {
|
||||
_hasMultipleInstances = has
|
||||
renderSidebar(el)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export function renderSidebar(el) {
|
||||
const current = getCurrentRoute()
|
||||
|
||||
const inst = getActiveInstance()
|
||||
const isLocal = inst.type === 'local'
|
||||
const showSwitcher = !isLocal || _hasMultipleInstances
|
||||
|
||||
let html = `
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">
|
||||
@@ -102,6 +126,14 @@ export function renderSidebar(el) {
|
||||
</div>
|
||||
<span class="sidebar-title">ClawPanel</span>
|
||||
</div>
|
||||
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
|
||||
<button class="instance-current" id="btn-instance-toggle">
|
||||
<span class="instance-dot ${isLocal ? 'local' : 'remote'}"></span>
|
||||
<span class="instance-label">${_escSidebar(inst.name)}</span>
|
||||
<svg class="instance-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="instance-dropdown" id="instance-dropdown"></div>
|
||||
</div>` : ''}
|
||||
<nav class="sidebar-nav">
|
||||
`
|
||||
|
||||
@@ -143,6 +175,9 @@ export function renderSidebar(el) {
|
||||
|
||||
el.innerHTML = html
|
||||
|
||||
// 首次渲染时异步检测多实例
|
||||
if (!_delegated) _checkMultiInstances(el)
|
||||
|
||||
// 事件委托:只绑定一次,避免重复绑定
|
||||
if (!_delegated) {
|
||||
_delegated = true
|
||||
@@ -158,7 +193,132 @@ export function renderSidebar(el) {
|
||||
if (themeBtn) {
|
||||
toggleTheme()
|
||||
renderSidebar(el)
|
||||
return
|
||||
}
|
||||
// 实例切换器
|
||||
const toggleBtn = e.target.closest('#btn-instance-toggle')
|
||||
if (toggleBtn) {
|
||||
_toggleInstanceDropdown(el)
|
||||
return
|
||||
}
|
||||
// 选择实例
|
||||
const opt = e.target.closest('.instance-option[data-id]')
|
||||
if (opt) {
|
||||
const id = opt.dataset.id
|
||||
_closeInstanceDropdown()
|
||||
if (id !== getActiveInstance().id) {
|
||||
opt.style.opacity = '0.5'
|
||||
switchInstance(id).then(() => {
|
||||
renderSidebar(el)
|
||||
navigate(getCurrentRoute())
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// 添加实例
|
||||
const addBtn = e.target.closest('#btn-instance-add')
|
||||
if (addBtn) {
|
||||
_closeInstanceDropdown()
|
||||
_showAddInstanceDialog(el)
|
||||
return
|
||||
}
|
||||
// 点击其他区域关闭下拉
|
||||
if (!e.target.closest('.instance-switcher')) {
|
||||
_closeInstanceDropdown()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听实例变化,刷新多实例标记后重新渲染
|
||||
onInstanceChange(() => { _checkMultiInstances(el); renderSidebar(el) })
|
||||
}
|
||||
}
|
||||
|
||||
function _escSidebar(s) { return String(s || '').replace(/</g, '<').replace(/>/g, '>') }
|
||||
|
||||
function _closeInstanceDropdown() {
|
||||
const dd = document.getElementById('instance-dropdown')
|
||||
if (dd) dd.classList.remove('open')
|
||||
}
|
||||
|
||||
async function _toggleInstanceDropdown(sidebarEl) {
|
||||
const dd = document.getElementById('instance-dropdown')
|
||||
if (!dd) return
|
||||
if (dd.classList.contains('open')) { dd.classList.remove('open'); return }
|
||||
|
||||
dd.innerHTML = '<div style="padding:8px;color:var(--text-tertiary);font-size:12px">loading...</div>'
|
||||
dd.classList.add('open')
|
||||
|
||||
try {
|
||||
const [data, health] = await Promise.all([api.instanceList(), api.instanceHealthAll()])
|
||||
const healthMap = Object.fromEntries((health || []).map(h => [h.id, h]))
|
||||
const activeId = getActiveInstance().id
|
||||
let html = ''
|
||||
for (const inst of data.instances) {
|
||||
const h = healthMap[inst.id] || {}
|
||||
const active = inst.id === activeId ? ' active' : ''
|
||||
const dot = h.online !== false ? 'online' : 'offline'
|
||||
const badge = inst.type === 'docker' ? '<span class="instance-badge">Docker</span>' : inst.type === 'remote' ? '<span class="instance-badge">Remote</span>' : ''
|
||||
html += `<div class="instance-option${active}" data-id="${inst.id}">
|
||||
<span class="instance-dot ${dot}"></span>
|
||||
<span class="instance-opt-name">${_escSidebar(inst.name)}</span>
|
||||
${badge}
|
||||
</div>`
|
||||
}
|
||||
html += '<div class="instance-divider"></div>'
|
||||
html += '<div class="instance-option instance-add" id="btn-instance-add">+ Add Instance</div>'
|
||||
dd.innerHTML = html
|
||||
} catch (e) {
|
||||
dd.innerHTML = `<div style="padding:8px;color:var(--error);font-size:12px">${_escSidebar(e.message)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function _showAddInstanceDialog(sidebarEl) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'docker-dialog-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="docker-dialog">
|
||||
<div class="docker-dialog-title">Add Instance</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-md)">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-input" id="inst-name" placeholder="My Server" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-md)">
|
||||
<label class="form-label">Panel Endpoint</label>
|
||||
<input class="form-input" id="inst-endpoint" placeholder="http://192.168.1.100:1420" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-md)">
|
||||
<label class="form-label">Gateway Port (optional)</label>
|
||||
<input class="form-input" id="inst-gw-port" type="number" value="18789" />
|
||||
</div>
|
||||
<div class="docker-dialog-hint">
|
||||
The remote server must be running ClawPanel (serve.js).<br/>
|
||||
Example: <code>http://192.168.1.100:1420</code>
|
||||
</div>
|
||||
<div id="inst-add-error" style="color:var(--error);font-size:12px;margin-top:var(--space-sm)"></div>
|
||||
<div class="docker-dialog-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="inst-cancel">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" id="inst-confirm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
overlay.querySelector('#inst-cancel').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
overlay.querySelector('#inst-confirm').onclick = async () => {
|
||||
const name = overlay.querySelector('#inst-name').value.trim()
|
||||
const endpoint = overlay.querySelector('#inst-endpoint').value.trim()
|
||||
const gwPort = parseInt(overlay.querySelector('#inst-gw-port').value) || 18789
|
||||
const errEl = overlay.querySelector('#inst-add-error')
|
||||
if (!name || !endpoint) { errEl.textContent = 'Name and endpoint are required'; return }
|
||||
const btn = overlay.querySelector('#inst-confirm')
|
||||
btn.disabled = true; btn.textContent = 'Adding...'
|
||||
try {
|
||||
await api.instanceAdd({ name, type: 'remote', endpoint, gatewayPort: gwPort })
|
||||
overlay.remove()
|
||||
renderSidebar(sidebarEl)
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || String(e)
|
||||
btn.disabled = false; btn.textContent = 'Add'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { api } from './tauri-api.js'
|
||||
let _openclawReady = false
|
||||
let _gatewayRunning = false
|
||||
let _platform = '' // 'macos' | 'win32' | ...
|
||||
let _deployMode = 'local' // 'local' | 'docker'
|
||||
let _inDocker = false
|
||||
let _dockerAvailable = false
|
||||
let _listeners = []
|
||||
let _gwListeners = []
|
||||
let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用
|
||||
@@ -54,6 +57,40 @@ export function isMacPlatform() {
|
||||
return _platform === 'macos'
|
||||
}
|
||||
|
||||
/** 部署模式 */
|
||||
export function getDeployMode() { return _deployMode }
|
||||
export function isInDocker() { return _inDocker }
|
||||
export function isDockerAvailable() { return _dockerAvailable }
|
||||
|
||||
/** 实例管理 */
|
||||
let _activeInstance = { id: 'local', name: '本机', type: 'local' }
|
||||
let _instanceListeners = []
|
||||
|
||||
export function getActiveInstance() { return _activeInstance }
|
||||
export function isLocalInstance() { return _activeInstance.type === 'local' }
|
||||
|
||||
export function onInstanceChange(fn) {
|
||||
_instanceListeners.push(fn)
|
||||
return () => { _instanceListeners = _instanceListeners.filter(cb => cb !== fn) }
|
||||
}
|
||||
|
||||
export async function switchInstance(id) {
|
||||
// instanceSetActive 内部已调用 _cache.clear(),切换后所有缓存自动失效
|
||||
await api.instanceSetActive(id)
|
||||
const data = await api.instanceList()
|
||||
_activeInstance = data.instances.find(i => i.id === id) || data.instances[0]
|
||||
_instanceListeners.forEach(fn => { try { fn(_activeInstance) } catch {} })
|
||||
}
|
||||
|
||||
export async function loadActiveInstance() {
|
||||
try {
|
||||
const data = await api.instanceList()
|
||||
_activeInstance = data.instances.find(i => i.id === data.activeId) || data.instances[0]
|
||||
} catch {
|
||||
_activeInstance = { id: 'local', name: '本机', type: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听 Gateway 状态变化 */
|
||||
export function onGatewayChange(fn) {
|
||||
_gwListeners.push(fn)
|
||||
@@ -71,6 +108,10 @@ export async function detectOpenclawStatus() {
|
||||
if (installation.status === 'fulfilled' && installation.value?.platform) {
|
||||
_platform = installation.value.platform
|
||||
}
|
||||
if (installation.status === 'fulfilled' && installation.value?.inDocker) {
|
||||
_inDocker = true
|
||||
_deployMode = 'docker'
|
||||
}
|
||||
const cliInstalled = services.status === 'fulfilled'
|
||||
&& services.value?.length > 0
|
||||
&& services.value[0]?.cli_installed !== false
|
||||
|
||||
@@ -15,6 +15,23 @@ const NO_MOCK_CMDS = new Set([
|
||||
'set_npm_registry', 'reload_gateway', 'restart_gateway',
|
||||
'auto_pair_device',
|
||||
'assistant_exec', 'assistant_write_file',
|
||||
'docker_create_container', 'docker_start_container', 'docker_stop_container',
|
||||
'docker_restart_container', 'docker_remove_container', 'docker_pull_image',
|
||||
'docker_add_node', 'docker_remove_node',
|
||||
'instance_add', 'instance_remove', 'instance_set_active',
|
||||
])
|
||||
|
||||
// 仅在 Node.js 后端实现的命令(Tauri Rust 不处理),强制走 webInvoke
|
||||
const WEB_ONLY_CMDS = new Set([
|
||||
'docker_test_endpoint',
|
||||
'docker_info', 'docker_list_containers', 'docker_create_container',
|
||||
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
||||
'docker_remove_container', 'docker_container_logs', 'docker_pull_image',
|
||||
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
|
||||
'docker_cluster_overview',
|
||||
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
|
||||
'instance_health_check', 'instance_health_all',
|
||||
'get_deploy_mode',
|
||||
])
|
||||
|
||||
// 预加载 Tauri invoke,避免每次 API 调用都做动态 import
|
||||
@@ -79,7 +96,7 @@ export { invalidate }
|
||||
|
||||
async function invoke(cmd, args = {}) {
|
||||
const start = Date.now()
|
||||
if (_invokeReady) {
|
||||
if (_invokeReady && !WEB_ONLY_CMDS.has(cmd)) {
|
||||
const tauriInvoke = await _invokeReady
|
||||
const result = await tauriInvoke(cmd, args)
|
||||
const duration = Date.now() - start
|
||||
@@ -113,8 +130,9 @@ async function webInvoke(cmd, args) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
})
|
||||
if (resp.status === 401 && window.__clawpanel_show_login) {
|
||||
window.__clawpanel_show_login()
|
||||
if (resp.status === 401) {
|
||||
// Tauri 模式下不触发登录浮层(Tauri 有自己的认证流程)
|
||||
if (!isTauri && window.__clawpanel_show_login) window.__clawpanel_show_login()
|
||||
throw new Error('需要登录')
|
||||
}
|
||||
if (!resp.ok) {
|
||||
@@ -193,7 +211,28 @@ function mockInvoke(cmd, args) {
|
||||
write_memory_file: () => true,
|
||||
delete_memory_file: () => true,
|
||||
export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`,
|
||||
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }),
|
||||
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23', inDocker: false }),
|
||||
get_deploy_mode: () => ({ inDocker: false, dockerAvailable: true, mode: 'local' }),
|
||||
docker_cluster_overview: () => [
|
||||
{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock', online: true, dockerVersion: '27.4.1', os: 'Docker Desktop', cpus: 8, memory: 16 * 1024 * 1024 * 1024, totalContainers: 3, runningContainers: 2, stoppedContainers: 1, containers: [
|
||||
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789' },
|
||||
{ id: 'f6e5d4c3b2a1', name: 'openclaw-gw-2', image: 'ghcr.io/qingchencloud/openclaw:latest-gateway', state: 'running', status: 'Up 5 hours', ports: '18790→18789' },
|
||||
{ id: 'b3c4d5e6f7a8', name: 'openclaw-test', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'exited', status: 'Exited (0) 1 day ago', ports: '' },
|
||||
]},
|
||||
],
|
||||
docker_list_containers: () => [
|
||||
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789', nodeId: 'local', nodeName: '本机' },
|
||||
],
|
||||
docker_info: () => ({ nodeId: 'local', nodeName: '本机', containers: 3, containersRunning: 2, containersStopped: 1, images: 5, serverVersion: '27.4.1', os: 'Docker Desktop', arch: 'x86_64', cpus: 8, memory: 16 * 1024 * 1024 * 1024 }),
|
||||
docker_list_nodes: () => [{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock' }],
|
||||
docker_container_logs: () => '[INFO] OpenClaw Gateway started\n[INFO] Listening on :18789\n[INFO] Panel available at :1420',
|
||||
docker_list_images: () => [{ id: 'sha256abcdef', tags: ['ghcr.io/qingchencloud/openclaw:latest'], size: 450 * 1024 * 1024, created: Date.now() / 1000 - 86400 }],
|
||||
instance_list: () => ({ activeId: 'local', instances: [{ id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }] }),
|
||||
instance_add: () => ({ id: 'remote-mock', name: 'mock' }),
|
||||
instance_remove: () => true,
|
||||
instance_set_active: () => ({ activeId: 'local' }),
|
||||
instance_health_check: () => ({ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }),
|
||||
instance_health_all: () => [{ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }],
|
||||
check_node: () => ({ installed: true, version: 'v20.11.0' }),
|
||||
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
|
||||
read_mcp_config: () => ({
|
||||
@@ -362,6 +401,32 @@ export const api = {
|
||||
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
|
||||
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
|
||||
|
||||
// 实例管理
|
||||
instanceList: () => cachedInvoke('instance_list', {}, 10000),
|
||||
instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) },
|
||||
instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) },
|
||||
instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) },
|
||||
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
|
||||
instanceHealthAll: () => invoke('instance_health_all'),
|
||||
|
||||
// Docker 集群管理
|
||||
getDeployMode: () => cachedInvoke('get_deploy_mode', {}, 60000),
|
||||
dockerClusterOverview: () => invoke('docker_cluster_overview'),
|
||||
dockerTestEndpoint: (endpoint) => invoke('docker_test_endpoint', { endpoint }),
|
||||
dockerInfo: (nodeId) => invoke('docker_info', { nodeId }),
|
||||
dockerListContainers: (nodeId, all = true) => invoke('docker_list_containers', { nodeId, all }),
|
||||
dockerCreateContainer: (opts) => invoke('docker_create_container', opts),
|
||||
dockerStartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_start_container', { nodeId, containerId }) },
|
||||
dockerStopContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_stop_container', { nodeId, containerId }) },
|
||||
dockerRestartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_restart_container', { nodeId, containerId }) },
|
||||
dockerRemoveContainer: (nodeId, containerId, force = false) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_remove_container', { nodeId, containerId, force }) },
|
||||
dockerContainerLogs: (nodeId, containerId, tail = 200) => invoke('docker_container_logs', { nodeId, containerId, tail }),
|
||||
dockerPullImage: (nodeId, image, tag) => invoke('docker_pull_image', { nodeId, image, tag }),
|
||||
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId }),
|
||||
dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
|
||||
dockerAddNode: (name, endpoint) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_add_node', { name, endpoint }) },
|
||||
dockerRemoveNode: (nodeId) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_remove_node', { nodeId }) },
|
||||
|
||||
// 前端热更新
|
||||
checkFrontendUpdate: () => invoke('check_frontend_update'),
|
||||
downloadFrontendUpdate: (url, expectedHash) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '' }),
|
||||
|
||||
214
src/main.js
214
src/main.js
@@ -4,7 +4,7 @@
|
||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||
import { renderSidebar } from './components/sidebar.js'
|
||||
import { initTheme } from './lib/theme.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { wsClient } from './lib/ws-client.js'
|
||||
import { api } from './lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../package.json'
|
||||
@@ -60,10 +60,20 @@ function _hideSplash() {
|
||||
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500) }
|
||||
}
|
||||
|
||||
let _loginFailCount = 0
|
||||
const CAPTCHA_THRESHOLD = 3
|
||||
|
||||
function _genCaptcha() {
|
||||
const a = Math.floor(Math.random() * 20) + 1
|
||||
const b = Math.floor(Math.random() * 20) + 1
|
||||
return { q: `${a} + ${b} = ?`, a: a + b }
|
||||
}
|
||||
|
||||
function showLoginOverlay(defaultPw) {
|
||||
const hasDefault = !!defaultPw
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'login-overlay'
|
||||
let _captcha = _loginFailCount >= CAPTCHA_THRESHOLD ? _genCaptcha() : null
|
||||
overlay.innerHTML = `
|
||||
<div class="login-card">
|
||||
${_logoSvg}
|
||||
@@ -73,10 +83,23 @@ function showLoginOverlay(defaultPw) {
|
||||
: (isTauri ? '应用已锁定,请输入密码' : '请输入访问密码')}</div>
|
||||
<form id="login-form">
|
||||
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="访问密码" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
|
||||
<div id="login-captcha" style="display:${_captcha ? 'block' : 'none'};margin-bottom:10px">
|
||||
<div style="font-size:12px;color:#888;margin-bottom:6px">请先完成验证:<strong id="captcha-q" style="color:var(--text-primary,#333)">${_captcha ? _captcha.q : ''}</strong></div>
|
||||
<input class="login-input" type="number" id="login-captcha-input" placeholder="输入计算结果" style="text-align:center" />
|
||||
</div>
|
||||
<button class="login-btn" type="submit">登 录</button>
|
||||
<div class="login-error" id="login-error"></div>
|
||||
</form>
|
||||
<div style="margin-top:20px;font-size:11px;color:#aaa;text-align:center">
|
||||
${!hasDefault ? `<details class="login-forgot" style="margin-top:16px;text-align:center">
|
||||
<summary style="font-size:11px;color:#aaa;cursor:pointer;list-style:none;user-select:none">忘记密码?</summary>
|
||||
<div style="margin-top:8px;font-size:11px;color:#888;line-height:1.8;text-align:left;background:rgba(0,0,0,.03);border-radius:8px;padding:10px 14px">
|
||||
${isTauri
|
||||
? '删除配置文件中的 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段即可重置:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
|
||||
: '编辑服务器上的配置文件,删除 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段后重启服务:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
|
||||
}
|
||||
</div>
|
||||
</details>` : ''}
|
||||
<div style="margin-top:${hasDefault ? '20' : '12'}px;font-size:11px;color:#aaa;text-align:center">
|
||||
<a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:#aaa;text-decoration:none">claw.qt.cool</a>
|
||||
<span style="margin:0 6px">·</span>v${APP_VERSION}
|
||||
</div>
|
||||
@@ -94,18 +117,46 @@ function showLoginOverlay(defaultPw) {
|
||||
btn.disabled = true
|
||||
btn.textContent = '登录中...'
|
||||
errEl.textContent = ''
|
||||
// 验证码校验
|
||||
if (_captcha) {
|
||||
const captchaVal = parseInt(overlay.querySelector('#login-captcha-input')?.value)
|
||||
if (captchaVal !== _captcha.a) {
|
||||
errEl.textContent = '验证码错误'
|
||||
_captcha = _genCaptcha()
|
||||
const qEl = overlay.querySelector('#captcha-q')
|
||||
if (qEl) qEl.textContent = _captcha.q
|
||||
overlay.querySelector('#login-captcha-input').value = ''
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (isTauri) {
|
||||
// 桌面端:本地比对密码
|
||||
const { api } = await import('./lib/tauri-api.js')
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (pw !== cfg.accessPassword) {
|
||||
errEl.textContent = '密码错误'
|
||||
_loginFailCount++
|
||||
if (_loginFailCount >= CAPTCHA_THRESHOLD && !_captcha) {
|
||||
_captcha = _genCaptcha()
|
||||
const cEl = overlay.querySelector('#login-captcha')
|
||||
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
|
||||
}
|
||||
errEl.textContent = `密码错误${_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`}`
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem('clawpanel_authed', '1')
|
||||
// 同步建立 web session(WEB_ONLY_CMDS 需要 cookie 认证)
|
||||
try {
|
||||
await fetch('/__api/auth_login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
})
|
||||
} catch {}
|
||||
overlay.classList.add('hide')
|
||||
setTimeout(() => overlay.remove(), 400)
|
||||
if (cfg.accessPassword === '123456') {
|
||||
@@ -121,7 +172,13 @@ function showLoginOverlay(defaultPw) {
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok) {
|
||||
errEl.textContent = data.error || '登录失败'
|
||||
_loginFailCount++
|
||||
if (_loginFailCount >= CAPTCHA_THRESHOLD && !_captcha) {
|
||||
_captcha = _genCaptcha()
|
||||
const cEl = overlay.querySelector('#login-captcha')
|
||||
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
|
||||
}
|
||||
errEl.textContent = (data.error || '登录失败') + (_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`)
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
return
|
||||
@@ -169,6 +226,7 @@ async function boot() {
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
registerRoute('/setup', () => import('./pages/setup.js'))
|
||||
registerRoute('/docker', () => import('./pages/docker.js'))
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
@@ -193,8 +251,20 @@ async function boot() {
|
||||
document.body.prepend(banner)
|
||||
}
|
||||
|
||||
// 后台检测状态,检测完再决定是否跳转 setup
|
||||
detectOpenclawStatus().then(() => {
|
||||
// Tauri 模式:确保 web session 存在(页面刷新后 cookie 可能丢失),然后加载实例和检测状态
|
||||
const ensureWebSession = isTauri
|
||||
? api.readPanelConfig().then(cfg => {
|
||||
if (cfg.accessPassword) {
|
||||
return fetch('/__api/auth_login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: cfg.accessPassword }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}).catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => {
|
||||
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
|
||||
renderSidebar(sidebar)
|
||||
if (!isOpenclawReady()) {
|
||||
@@ -223,13 +293,21 @@ async function boot() {
|
||||
onGuardianGiveUp(() => {
|
||||
showGuardianRecovery()
|
||||
})
|
||||
|
||||
// 实例切换时,重连 WebSocket + 重新检测状态
|
||||
onInstanceChange(async () => {
|
||||
wsClient.disconnect()
|
||||
await detectOpenclawStatus()
|
||||
if (isGatewayRunning()) autoConnectWebSocket()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function autoConnectWebSocket() {
|
||||
try {
|
||||
console.log('[main] 自动连接 WebSocket...')
|
||||
const inst = getActiveInstance()
|
||||
console.log(`[main] 自动连接 WebSocket (实例: ${inst.name})...`)
|
||||
const config = await api.readOpenclawConfig()
|
||||
const port = config?.gateway?.port || 18789
|
||||
const token = config?.gateway?.auth?.token || ''
|
||||
@@ -270,9 +348,20 @@ async function autoConnectWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
let host
|
||||
const inst2 = getActiveInstance()
|
||||
if (inst2.type !== 'local' && inst2.endpoint) {
|
||||
try {
|
||||
const url = new URL(inst2.endpoint)
|
||||
host = `${url.hostname}:${inst2.gatewayPort || port}`
|
||||
} catch {
|
||||
host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
}
|
||||
} else {
|
||||
host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
}
|
||||
wsClient.connect(host, token)
|
||||
console.log('[main] WebSocket 连接已启动')
|
||||
console.log(`[main] WebSocket 连接已启动 -> ${host}`)
|
||||
} catch (e) {
|
||||
console.error('[main] 自动连接 WebSocket 失败:', e)
|
||||
}
|
||||
@@ -382,11 +471,118 @@ function showGuardianRecovery() {
|
||||
})
|
||||
}
|
||||
|
||||
// === 全局版本更新检测 ===
|
||||
const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000 // 30 分钟
|
||||
let _updateCheckTimer = null
|
||||
|
||||
async function checkGlobalUpdate() {
|
||||
const banner = document.getElementById('update-banner')
|
||||
if (!banner) return
|
||||
|
||||
try {
|
||||
const info = await api.checkFrontendUpdate()
|
||||
if (!info.hasUpdate) return
|
||||
|
||||
const ver = info.latestVersion || info.manifest?.version || ''
|
||||
if (!ver) return
|
||||
|
||||
// 用户已忽略过该版本,不再打扰
|
||||
const dismissed = sessionStorage.getItem('clawpanel_update_dismissed')
|
||||
if (dismissed === ver) return
|
||||
|
||||
const changelog = info.manifest?.changelog || ''
|
||||
const isWeb = !window.__TAURI_INTERNALS__
|
||||
|
||||
banner.classList.remove('update-banner-hidden')
|
||||
banner.innerHTML = `
|
||||
<div class="update-banner-content">
|
||||
<div class="update-banner-text">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="update-banner-ver">ClawPanel v${ver} 可用</span>
|
||||
${changelog ? `<span class="update-banner-changelog">· ${changelog}</span>` : ''}
|
||||
</div>
|
||||
${isWeb
|
||||
? `<button class="btn btn-sm" id="btn-update-show-cmd">更新方法</button>
|
||||
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">Release Notes</a>`
|
||||
: `<button class="btn btn-sm" id="btn-update-hot">热更新</button>
|
||||
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">完整安装包</a>`
|
||||
}
|
||||
<button class="update-banner-close" id="btn-update-dismiss" title="忽略此版本">✕</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 关闭按钮:记住忽略的版本
|
||||
banner.querySelector('#btn-update-dismiss')?.addEventListener('click', () => {
|
||||
sessionStorage.setItem('clawpanel_update_dismissed', ver)
|
||||
banner.classList.add('update-banner-hidden')
|
||||
})
|
||||
|
||||
// Web 模式:显示更新命令弹窗
|
||||
banner.querySelector('#btn-update-show-cmd')?.addEventListener('click', () => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:480px">
|
||||
<div class="modal-title">更新到 v${ver}</div>
|
||||
<div style="font-size:var(--font-size-sm);line-height:1.8">
|
||||
<p style="margin-bottom:12px">在服务器上执行以下命令:</p>
|
||||
<pre style="background:var(--bg-tertiary);padding:12px 16px;border-radius:var(--radius-md);font-family:var(--font-mono);font-size:var(--font-size-xs);overflow-x:auto;white-space:pre-wrap;user-select:all">cd /opt/clawpanel
|
||||
git pull origin main
|
||||
npm install
|
||||
npm run build
|
||||
sudo systemctl restart clawpanel</pre>
|
||||
<p style="margin-top:12px;color:var(--text-tertiary);font-size:var(--font-size-xs)">
|
||||
如果 git pull 失败,可先执行 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 丢弃本地修改。<br>
|
||||
路径请替换为实际的 ClawPanel 安装目录。
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
overlay.querySelector('[data-action="close"]').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.remove() })
|
||||
})
|
||||
|
||||
// Tauri 热更新按钮
|
||||
banner.querySelector('#btn-update-hot')?.addEventListener('click', async () => {
|
||||
const btn = banner.querySelector('#btn-update-hot')
|
||||
if (!btn) return
|
||||
btn.disabled = true
|
||||
btn.textContent = '下载中...'
|
||||
try {
|
||||
await api.downloadFrontendUpdate(info.manifest?.url || '', info.manifest?.hash || '')
|
||||
btn.textContent = '重载应用'
|
||||
btn.disabled = false
|
||||
btn.onclick = () => window.location.reload()
|
||||
} catch (e) {
|
||||
btn.textContent = '下载失败'
|
||||
btn.disabled = false
|
||||
const { toast } = await import('./components/toast.js')
|
||||
toast('更新下载失败: ' + (e.message || e), 'error')
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 检查失败静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
function startUpdateChecker() {
|
||||
// 启动后 5 秒检查一次
|
||||
setTimeout(checkGlobalUpdate, 5000)
|
||||
// 之后每 30 分钟检查一次
|
||||
_updateCheckTimer = setInterval(checkGlobalUpdate, UPDATE_CHECK_INTERVAL)
|
||||
}
|
||||
|
||||
// 启动:先检查认证,再加载应用
|
||||
;(async () => {
|
||||
const auth = await checkAuth()
|
||||
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
|
||||
boot()
|
||||
startUpdateChecker()
|
||||
|
||||
// 初始化全局 AI 助手浮动按钮(延迟加载,不阻塞启动)
|
||||
setTimeout(async () => {
|
||||
|
||||
@@ -64,12 +64,12 @@ async function loadData(page) {
|
||||
])
|
||||
|
||||
// 尝试从 Tauri API 获取 ClawPanel 自身版本号,失败则 fallback
|
||||
let panelVersion = '0.1.0'
|
||||
let panelVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.0'
|
||||
try {
|
||||
const { getVersion } = await import('@tauri-apps/api/app')
|
||||
panelVersion = await getVersion()
|
||||
} catch {
|
||||
// 非 Tauri 环境或 API 不可用,使用 fallback
|
||||
// 非 Tauri 环境或 API 不可用,使用构建时注入的版本号
|
||||
}
|
||||
|
||||
// 异步检查前端热更新
|
||||
|
||||
618
src/pages/docker.js
Normal file
618
src/pages/docker.js
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Docker 集群管理页面
|
||||
* 管理 OpenClaw Docker 容器集群:节点管理、容器 CRUD、日志查看
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm } from '../components/modal.js'
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
// OpenClaw 容器识别
|
||||
const OPENCLAW_PATTERNS = ['openclaw', 'qingchencloud']
|
||||
function isOpenClawContainer(c) {
|
||||
const img = (c.image || '').toLowerCase()
|
||||
return OPENCLAW_PATTERNS.some(p => img.includes(p))
|
||||
}
|
||||
|
||||
// 用户手动纳入管理的容器 ID 持久化
|
||||
const ADOPTED_KEY = 'clawpanel_adopted_containers'
|
||||
function getAdoptedIds() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(ADOPTED_KEY) || '[]')) }
|
||||
catch { return new Set() }
|
||||
}
|
||||
function saveAdoptedIds(ids) {
|
||||
localStorage.setItem(ADOPTED_KEY, JSON.stringify([...ids]))
|
||||
}
|
||||
function isManagedContainer(c) {
|
||||
return isOpenClawContainer(c) || getAdoptedIds().has(c.id)
|
||||
}
|
||||
|
||||
let _refreshTimer = null
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Docker 集群管理</h1>
|
||||
<p class="page-desc">管理 OpenClaw Docker 容器集群,快速部署和扩展</p>
|
||||
</div>
|
||||
<div id="docker-stats" class="stat-cards"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
|
||||
<div id="docker-nodes" style="margin-top:var(--space-lg)"><div class="stat-card loading-placeholder" style="height:120px"></div></div>
|
||||
<div id="docker-containers" style="margin-top:var(--space-lg)"><div class="stat-card loading-placeholder" style="height:200px"></div></div>
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
await loadClusterOverview(page)
|
||||
|
||||
_refreshTimer = setInterval(() => loadClusterOverview(page), 30000)
|
||||
return page
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null }
|
||||
}
|
||||
|
||||
async function loadClusterOverview(page) {
|
||||
try {
|
||||
const nodes = await api.dockerClusterOverview()
|
||||
renderStats(page, nodes)
|
||||
renderNodes(page, nodes)
|
||||
renderContainers(page, nodes)
|
||||
} catch (e) {
|
||||
const statsEl = page.querySelector('#docker-stats')
|
||||
statsEl.innerHTML = `
|
||||
<div class="docker-empty">
|
||||
<div class="docker-empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><rect x="1" y="11" width="4" height="3" rx=".5"/><rect x="6" y="11" width="4" height="3" rx=".5"/><rect x="11" y="11" width="4" height="3" rx=".5"/><rect x="6" y="7" width="4" height="3" rx=".5"/><rect x="11" y="7" width="4" height="3" rx=".5"/><rect x="16" y="11" width="4" height="3" rx=".5"/><rect x="11" y="3" width="4" height="3" rx=".5"/><path d="M2 17c1 3 4 5 10 5s9-2 10-5"/></svg>
|
||||
</div>
|
||||
<div class="docker-empty-title">Docker 未连接</div>
|
||||
<div class="docker-empty-desc">${esc(e.message)}</div>
|
||||
<div class="docker-empty-hint">
|
||||
<p>确保 Docker 已安装并运行:</p>
|
||||
<code>docker info</code>
|
||||
<p style="margin-top:8px">如果在 Docker 容器内运行,请挂载 Docker Socket:</p>
|
||||
<code>-v /var/run/docker.sock:/var/run/docker.sock</code>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
page.querySelector('#docker-nodes').innerHTML = ''
|
||||
page.querySelector('#docker-containers').innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(page, nodes) {
|
||||
const el = page.querySelector('#docker-stats')
|
||||
const totalNodes = nodes.length
|
||||
const onlineNodes = nodes.filter(n => n.online).length
|
||||
// 统计托管容器(OpenClaw + 手动纳入)
|
||||
let managedTotal = 0, managedRunning = 0, managedStopped = 0
|
||||
for (const n of nodes) {
|
||||
if (!n.online || !n.containers) continue
|
||||
for (const c of n.containers) {
|
||||
if (isManagedContainer(c)) {
|
||||
managedTotal++
|
||||
if (c.state === 'running') managedRunning++
|
||||
else managedStopped++
|
||||
}
|
||||
}
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-value">${onlineNodes}<span class="stat-card-unit">/ ${totalNodes}</span></div>
|
||||
<div class="stat-card-label">节点在线</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-value">${managedTotal}</div>
|
||||
<div class="stat-card-label">托管容器</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-value" style="color:var(--success, #22c55e)">${managedRunning}</div>
|
||||
<div class="stat-card-label">运行中</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-value" style="color:var(--text-tertiary)">${managedStopped}</div>
|
||||
<div class="stat-card-label">已停止</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderNodes(page, nodes) {
|
||||
const el = page.querySelector('#docker-nodes')
|
||||
let html = `
|
||||
<div class="docker-section-header">
|
||||
<div class="docker-section-title">节点管理</div>
|
||||
<div class="docker-section-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="add-node">+ 添加节点</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docker-node-grid">
|
||||
`
|
||||
for (const node of nodes) {
|
||||
const statusClass = node.online ? 'online' : 'offline'
|
||||
const statusText = node.online ? '在线' : '离线'
|
||||
const mem = node.memory ? fmtBytes(node.memory) : '-'
|
||||
html += `
|
||||
<div class="docker-node-card ${statusClass}">
|
||||
<div class="docker-node-header">
|
||||
<div class="docker-node-status ${statusClass}"></div>
|
||||
<div class="docker-node-name">${esc(node.name)}</div>
|
||||
<div class="docker-node-badge">${statusText}</div>
|
||||
${node.id !== 'local' ? `<button class="docker-node-remove" data-action="remove-node" data-node-id="${esc(node.id)}" title="移除节点">×</button>` : ''}
|
||||
</div>
|
||||
<div class="docker-node-info">
|
||||
<span>${esc(node.endpoint)}</span>
|
||||
${node.online ? `<span>Docker ${esc(node.dockerVersion)}</span><span>${node.cpus || '-'} CPU · ${mem} RAM</span>` : `<span class="docker-node-error">${esc(node.error || '连接失败')}</span>`}
|
||||
</div>
|
||||
${node.online ? `
|
||||
<div class="docker-node-footer">
|
||||
<span>${node.runningContainers || 0} 运行 / ${node.totalContainers || 0} 总计</span>
|
||||
<button class="btn btn-sm" data-action="deploy" data-node-id="${esc(node.id)}">部署容器</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
html += '</div>'
|
||||
el.innerHTML = html
|
||||
}
|
||||
|
||||
function _renderContainerRow(c, showAdopt) {
|
||||
const isRunning = c.state === 'running'
|
||||
const stateClass = isRunning ? 'running' : 'stopped'
|
||||
const isAdopted = !isOpenClawContainer(c) && getAdoptedIds().has(c.id)
|
||||
return `<tr>
|
||||
<td><span class="docker-ct-name">${esc(c.name)}</span><span class="docker-ct-id">${esc(c.id)}</span></td>
|
||||
<td class="docker-ct-image">${esc(c.image)}</td>
|
||||
<td><span class="docker-ct-state ${stateClass}">${esc(c.status || c.state)}</span></td>
|
||||
<td class="docker-ct-ports">${esc(c.ports) || '-'}</td>
|
||||
<td class="docker-ct-actions">
|
||||
${showAdopt ? `
|
||||
<button class="btn btn-sm" data-action="adopt" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}">纳入管理</button>
|
||||
` : `
|
||||
${isRunning
|
||||
? `<button class="btn-icon" data-action="stop" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="停止">⏹</button>
|
||||
<button class="btn-icon" data-action="restart" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="重启">🔄</button>`
|
||||
: `<button class="btn-icon" data-action="start" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="启动">▶</button>`
|
||||
}
|
||||
<button class="btn-icon" data-action="logs" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="日志">📋</button>
|
||||
${isAdopted ? `<button class="btn-icon" data-action="unadopt" data-ct="${esc(c.id)}" title="取消管理">✕</button>` : ''}
|
||||
<button class="btn-icon danger" data-action="remove" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}" title="删除">🗑</button>
|
||||
`}
|
||||
</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
function renderContainers(page, nodes) {
|
||||
const el = page.querySelector('#docker-containers')
|
||||
const allContainers = []
|
||||
for (const node of nodes) {
|
||||
if (!node.online || !node.containers) continue
|
||||
for (const c of node.containers) {
|
||||
allContainers.push({ ...c, nodeId: node.id, nodeName: node.name })
|
||||
}
|
||||
}
|
||||
|
||||
const managed = allContainers.filter(c => isManagedContainer(c))
|
||||
const other = allContainers.filter(c => !isManagedContainer(c))
|
||||
|
||||
let html = `
|
||||
<div class="docker-section-header">
|
||||
<div class="docker-section-title">OpenClaw 容器</div>
|
||||
<div class="docker-section-actions">
|
||||
<button class="btn btn-sm" data-action="refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (managed.length === 0) {
|
||||
html += `<div class="docker-empty-inline">暂无 OpenClaw 容器,点击节点上的「部署容器」创建,或从下方已有容器中纳入管理</div>`
|
||||
} else {
|
||||
html += `<div class="docker-table-wrap"><table class="docker-table">
|
||||
<thead><tr>
|
||||
<th>名称</th><th>镜像</th><th>状态</th><th>端口</th><th>操作</th>
|
||||
</tr></thead><tbody>`
|
||||
for (const c of managed) html += _renderContainerRow(c, false)
|
||||
html += '</tbody></table></div>'
|
||||
}
|
||||
|
||||
// 其他容器(可折叠)
|
||||
if (other.length > 0) {
|
||||
html += `
|
||||
<details class="docker-other-section" style="margin-top:var(--space-lg)">
|
||||
<summary class="docker-other-toggle">
|
||||
<span>其他 Docker 容器</span>
|
||||
<span class="docker-other-count">${other.length}</span>
|
||||
</summary>
|
||||
<div class="docker-table-wrap" style="margin-top:var(--space-sm)"><table class="docker-table">
|
||||
<thead><tr>
|
||||
<th>名称</th><th>镜像</th><th>状态</th><th>端口</th><th>操作</th>
|
||||
</tr></thead><tbody>
|
||||
${other.map(c => _renderContainerRow(c, true)).join('')}
|
||||
</tbody></table></div>
|
||||
</details>
|
||||
`
|
||||
}
|
||||
|
||||
el.innerHTML = html
|
||||
}
|
||||
|
||||
function bindEvents(page) {
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
|
||||
if (action === 'refresh') {
|
||||
toast('刷新中...')
|
||||
await loadClusterOverview(page)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'add-node') {
|
||||
showAddNodeDialog(page)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'remove-node') {
|
||||
const nodeId = btn.dataset.nodeId
|
||||
const ok = await showConfirm('确定移除此节点?', '移除后该节点上的容器将不再在此面板中管理。')
|
||||
if (!ok) return
|
||||
try {
|
||||
await api.dockerRemoveNode(nodeId)
|
||||
toast('节点已移除')
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) { toast(e.message, 'error') }
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'deploy') {
|
||||
showDeployDialog(page, btn.dataset.nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'adopt') {
|
||||
const ids = getAdoptedIds()
|
||||
ids.add(btn.dataset.ct)
|
||||
saveAdoptedIds(ids)
|
||||
toast(`已将 ${btn.dataset.name || btn.dataset.ct} 纳入管理`)
|
||||
await loadClusterOverview(page)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'unadopt') {
|
||||
const ids = getAdoptedIds()
|
||||
ids.delete(btn.dataset.ct)
|
||||
saveAdoptedIds(ids)
|
||||
toast('已取消管理')
|
||||
await loadClusterOverview(page)
|
||||
return
|
||||
}
|
||||
|
||||
const containerId = btn.dataset.ct
|
||||
const nodeId = btn.dataset.node
|
||||
|
||||
if (action === 'start') {
|
||||
try {
|
||||
btn.disabled = true
|
||||
await api.dockerStartContainer(nodeId, containerId)
|
||||
toast('容器已启动')
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) { toast(e.message, 'error') }
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
try {
|
||||
btn.disabled = true
|
||||
await api.dockerStopContainer(nodeId, containerId)
|
||||
toast('容器已停止')
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) { toast(e.message, 'error') }
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'restart') {
|
||||
try {
|
||||
btn.disabled = true
|
||||
await api.dockerRestartContainer(nodeId, containerId)
|
||||
toast('容器已重启')
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) { toast(e.message, 'error') }
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'remove') {
|
||||
const name = btn.dataset.name || containerId
|
||||
const ok = await showConfirm(`删除容器 ${name}?`, '容器数据卷将保留,但容器本身将被删除。')
|
||||
if (!ok) return
|
||||
try {
|
||||
await api.dockerRemoveContainer(nodeId, containerId, true)
|
||||
toast('容器已删除')
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) { toast(e.message, 'error') }
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'logs') {
|
||||
showLogsDialog(page, nodeId, containerId)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showAddNodeDialog(page) {
|
||||
const isWin = navigator.platform?.toLowerCase().includes('win')
|
||||
const presets = [
|
||||
{ label: '本机 (TCP)', endpoint: 'tcp://127.0.0.1:2375', desc: '本机 Docker TCP 端口' },
|
||||
{ label: '本机 (Socket)', endpoint: isWin ? '//./pipe/docker_engine' : 'unix:///var/run/docker.sock', desc: isWin ? 'Windows Named Pipe' : 'Unix Socket' },
|
||||
]
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'docker-dialog-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="docker-dialog">
|
||||
<div class="docker-dialog-title">添加 Docker 节点</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">节点名称</label>
|
||||
<input class="form-input" id="dn-name" placeholder="如:生产服务器" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Docker 端点</label>
|
||||
<div class="dn-presets">
|
||||
${presets.map((p, i) => `<button class="dn-preset-btn" data-idx="${i}" title="${esc(p.desc)}">${esc(p.label)}</button>`).join('')}
|
||||
<button class="dn-preset-btn" data-idx="custom">自定义</button>
|
||||
</div>
|
||||
<div id="dn-endpoint-row" style="display:flex;gap:8px;align-items:center;margin-top:8px">
|
||||
<input class="form-input" id="dn-endpoint" placeholder="tcp://192.168.1.100:2375" style="flex:1" />
|
||||
<button class="btn btn-sm" id="dn-test" type="button" style="white-space:nowrap">测试连接</button>
|
||||
</div>
|
||||
<div id="dn-test-result" style="font-size:12px;margin-top:6px;min-height:18px"></div>
|
||||
</div>
|
||||
<div class="docker-dialog-hint">
|
||||
<strong>远程 Docker:</strong>需在目标机器开启 TCP 端口<br>
|
||||
<code>dockerd -H tcp://0.0.0.0:2375</code>
|
||||
</div>
|
||||
<div class="docker-dialog-actions">
|
||||
<button class="btn" data-dismiss>取消</button>
|
||||
<button class="btn btn-primary" id="dn-submit">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
|
||||
const epInput = overlay.querySelector('#dn-endpoint')
|
||||
const resultEl = overlay.querySelector('#dn-test-result')
|
||||
|
||||
// 预设按钮点击
|
||||
for (const btn of overlay.querySelectorAll('.dn-preset-btn')) {
|
||||
btn.onclick = () => {
|
||||
overlay.querySelectorAll('.dn-preset-btn').forEach(b => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
const idx = btn.dataset.idx
|
||||
if (idx === 'custom') {
|
||||
epInput.value = ''
|
||||
epInput.focus()
|
||||
} else {
|
||||
epInput.value = presets[parseInt(idx)].endpoint
|
||||
}
|
||||
resultEl.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
overlay.querySelector('#dn-test').onclick = async () => {
|
||||
const ep = epInput.value.trim()
|
||||
if (!ep) { resultEl.innerHTML = '<span style="color:var(--error,#ef4444)">请先输入端点</span>'; return }
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">连接中...</span>'
|
||||
try {
|
||||
const info = await api.dockerTestEndpoint(ep)
|
||||
resultEl.innerHTML = `<span style="color:var(--success,#22c55e)">✓ 连接成功 — Docker ${esc(info.ServerVersion || '?')},${info.Containers || 0} 个容器</span>`
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--error,#ef4444)">✕ 连接失败:${esc(e.message)}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('#dn-submit').onclick = async () => {
|
||||
const name = overlay.querySelector('#dn-name').value.trim()
|
||||
const endpoint = epInput.value.trim()
|
||||
if (!name || !endpoint) { toast('请填写完整', 'error'); return }
|
||||
const btn = overlay.querySelector('#dn-submit')
|
||||
btn.disabled = true
|
||||
btn.textContent = '连接中...'
|
||||
try {
|
||||
await api.dockerAddNode(name, endpoint)
|
||||
toast('节点添加成功')
|
||||
overlay.remove()
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) {
|
||||
toast(e.message, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '添加'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showDeployDialog(page, nodeId) {
|
||||
// 自动检测已用端口,分配下一组可用端口
|
||||
let usedPorts = new Set()
|
||||
try {
|
||||
const containers = await api.dockerListContainers(nodeId, true)
|
||||
for (const c of containers) {
|
||||
if (c.ports) {
|
||||
for (const p of c.ports.split(', ')) {
|
||||
const m = p.match(/^(\d+)/)
|
||||
if (m) usedPorts.add(parseInt(m[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
let autoPanel = 1421
|
||||
while (usedPorts.has(autoPanel)) autoPanel++
|
||||
let autoGw = 18790
|
||||
while (usedPorts.has(autoGw)) autoGw++
|
||||
|
||||
const defaultName = `openclaw-${Date.now().toString(36).slice(-4)}`
|
||||
const defaultImage = 'ghcr.io/qingchencloud/openclaw:latest'
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'docker-dialog-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="docker-dialog">
|
||||
<div class="docker-dialog-title" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>部署 OpenClaw 容器</span>
|
||||
<div class="deploy-mode-toggle">
|
||||
<button class="deploy-mode-btn active" data-mode="basic">基础</button>
|
||||
<button class="deploy-mode-btn" data-mode="advanced">高级</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">容器名称</label>
|
||||
<input class="form-input" id="dd-name" placeholder="给你的 OpenClaw 起个名字" value="${defaultName}" />
|
||||
</div>
|
||||
|
||||
<div id="deploy-basic-info" class="deploy-auto-summary">
|
||||
<div class="deploy-auto-title">自动配置</div>
|
||||
<div class="deploy-auto-item"><span>镜像</span><span>一体版 (latest)</span></div>
|
||||
<div class="deploy-auto-item"><span>面板端口</span><span>${autoPanel}</span></div>
|
||||
<div class="deploy-auto-item"><span>Gateway 端口</span><span>${autoGw}</span></div>
|
||||
<div class="deploy-auto-item"><span>数据卷</span><span>自动创建</span></div>
|
||||
<div class="deploy-auto-item"><span>重启策略</span><span>unless-stopped</span></div>
|
||||
</div>
|
||||
|
||||
<div id="deploy-advanced-fields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label class="form-label">镜像</label>
|
||||
<select class="form-input" id="dd-image">
|
||||
<option value="ghcr.io/qingchencloud/openclaw:latest">一体版 (latest)</option>
|
||||
<option value="ghcr.io/qingchencloud/openclaw:latest-gateway">纯 Gateway (gateway)</option>
|
||||
<option value="ccr.ccs.tencentyun.com/qingchencloud/openclaw:latest">一体版 - 国内源 (腾讯云)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-sm)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">面板端口</label>
|
||||
<input class="form-input" id="dd-panel-port" type="number" value="${autoPanel}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Gateway 端口</label>
|
||||
<input class="form-input" id="dd-gw-port" type="number" value="${autoGw}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">环境变量 <span style="color:var(--text-tertiary)">(可选)</span></label>
|
||||
<textarea class="form-input" id="dd-env-key" rows="2" placeholder="OPENAI_API_KEY=sk-xxx" style="resize:vertical;font-family:var(--font-mono);font-size:12px"></textarea>
|
||||
<div class="form-hint">格式:KEY=VALUE,每行一个</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docker-dialog-actions">
|
||||
<button class="btn" data-dismiss>取消</button>
|
||||
<button class="btn btn-primary" id="dd-submit">一键部署</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
|
||||
// 基础/高级模式切换
|
||||
let isAdvanced = false
|
||||
for (const btn of overlay.querySelectorAll('.deploy-mode-btn')) {
|
||||
btn.onclick = () => {
|
||||
isAdvanced = btn.dataset.mode === 'advanced'
|
||||
overlay.querySelectorAll('.deploy-mode-btn').forEach(b => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
overlay.querySelector('#deploy-basic-info').style.display = isAdvanced ? 'none' : ''
|
||||
overlay.querySelector('#deploy-advanced-fields').style.display = isAdvanced ? '' : 'none'
|
||||
overlay.querySelector('#dd-submit').textContent = isAdvanced ? '部署' : '一键部署'
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('#dd-submit').onclick = async () => {
|
||||
const name = overlay.querySelector('#dd-name').value.trim()
|
||||
if (!name) { toast('请输入容器名称', 'error'); return }
|
||||
let image, tag, panelPort, gatewayPort, envVars = {}
|
||||
if (isAdvanced) {
|
||||
const imgFull = overlay.querySelector('#dd-image').value
|
||||
const parts = imgFull.split(':')
|
||||
tag = parts.pop()
|
||||
image = parts.join(':')
|
||||
panelPort = parseInt(overlay.querySelector('#dd-panel-port').value) || autoPanel
|
||||
gatewayPort = parseInt(overlay.querySelector('#dd-gw-port').value) || autoGw
|
||||
const envText = overlay.querySelector('#dd-env-key').value.trim()
|
||||
if (envText) {
|
||||
for (const line of envText.split('\n')) {
|
||||
const idx = line.indexOf('=')
|
||||
if (idx > 0) envVars[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parts = defaultImage.split(':')
|
||||
tag = parts.pop()
|
||||
image = parts.join(':')
|
||||
panelPort = autoPanel
|
||||
gatewayPort = autoGw
|
||||
}
|
||||
const btn = overlay.querySelector('#dd-submit')
|
||||
btn.disabled = true
|
||||
btn.textContent = '部署中...'
|
||||
try {
|
||||
const result = await api.dockerCreateContainer({ nodeId, name, image, tag, panelPort, gatewayPort, envVars })
|
||||
toast(`容器 ${result.name} 已部署并启动`)
|
||||
overlay.remove()
|
||||
await loadClusterOverview(page)
|
||||
} catch (e) {
|
||||
toast(e.message, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = isAdvanced ? '部署' : '一键部署'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showLogsDialog(page, nodeId, containerId) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'docker-dialog-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="docker-dialog docker-dialog-wide">
|
||||
<div class="docker-dialog-title">容器日志 <span style="color:var(--text-tertiary);font-size:12px">${esc(containerId)}</span></div>
|
||||
<pre class="docker-logs-content">加载中...</pre>
|
||||
<div class="docker-dialog-actions">
|
||||
<button class="btn" id="dl-refresh">刷新</button>
|
||||
<button class="btn" data-dismiss>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
|
||||
async function loadLogs() {
|
||||
const pre = overlay.querySelector('.docker-logs-content')
|
||||
try {
|
||||
const logs = await api.dockerContainerLogs(nodeId, containerId, 200)
|
||||
pre.textContent = logs || '(暂无日志)'
|
||||
pre.scrollTop = pre.scrollHeight
|
||||
} catch (e) {
|
||||
pre.textContent = '获取日志失败: ' + e.message
|
||||
}
|
||||
}
|
||||
await loadLogs()
|
||||
overlay.querySelector('#dl-refresh').onclick = loadLogs
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm, showUpgradeModal } from '../components/modal.js'
|
||||
import { isMacPlatform, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
|
||||
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
|
||||
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
|
||||
@@ -44,18 +44,21 @@ export async function render() {
|
||||
</div>
|
||||
`
|
||||
|
||||
// Docker 模式下隐藏 npm 源设置
|
||||
if (isInDocker()) {
|
||||
const regSection = page.querySelector('#registry-section')
|
||||
if (regSection) regSection.style.display = 'none'
|
||||
}
|
||||
|
||||
bindEvents(page)
|
||||
loadAll(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadAll(page) {
|
||||
await Promise.all([
|
||||
loadVersion(page),
|
||||
loadServices(page),
|
||||
loadRegistry(page),
|
||||
loadBackups(page),
|
||||
])
|
||||
const tasks = [loadVersion(page), loadServices(page), loadBackups(page)]
|
||||
if (!isInDocker()) tasks.push(loadRegistry(page))
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
// ===== 版本检测 =====
|
||||
@@ -74,21 +77,39 @@ async function loadVersion(page) {
|
||||
const sourceTag = isChinese ? '汉化优化版' : '官方原版'
|
||||
const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版'
|
||||
const switchTarget = isChinese ? 'official' : 'chinese'
|
||||
bar.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
|
||||
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
|
||||
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
|
||||
|
||||
if (isInDocker()) {
|
||||
bar.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">Docker 部署</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest + '(请拉取新镜像更新)' : '已是最新版本'}</div>
|
||||
${hasUpdate ? `<div style="margin-top:var(--space-sm)">
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
} else {
|
||||
bar.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
|
||||
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
|
||||
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">版本信息加载失败</div></div>`
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ async function loadRoute() {
|
||||
_contentEl.appendChild(spinnerEl)
|
||||
|
||||
try {
|
||||
mod = await withTimeout(loader(), 15000, '模块加载超时')
|
||||
mod = await retryLoad(loader, 3, 500)
|
||||
} catch (e) {
|
||||
console.error('[router] 模块加载失败:', hash, e)
|
||||
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
|
||||
@@ -106,6 +106,22 @@ async function loadRoute() {
|
||||
})
|
||||
}
|
||||
|
||||
async function retryLoad(loader, maxRetries, delayMs) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
try {
|
||||
return await withTimeout(loader(), 15000, '模块加载超时')
|
||||
} catch (e) {
|
||||
const isNetworkError = /fetch|network|connection|ERR_/i.test(String(e?.message || e))
|
||||
if (i < maxRetries && isNetworkError) {
|
||||
console.warn(`[router] 模块加载失败,${delayMs}ms 后重试 (${i + 1}/${maxRetries})...`)
|
||||
await new Promise(r => setTimeout(r, delayMs))
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, msg) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
|
||||
@@ -50,6 +50,105 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Instance Switcher === */
|
||||
.instance-switcher {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
position: relative;
|
||||
}
|
||||
.instance-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.instance-current:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.instance-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.instance-dot.local { background: var(--accent); }
|
||||
.instance-dot.remote { background: #f59e0b; }
|
||||
.instance-dot.online { background: var(--success, #22c55e); }
|
||||
.instance-dot.offline { background: var(--error, #ef4444); }
|
||||
.instance-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.instance-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.instance-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
top: 100%;
|
||||
z-index: 100;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: var(--space-xs) 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.instance-dropdown.open { display: block; }
|
||||
.instance-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.instance-option:hover { background: var(--bg-tertiary); }
|
||||
.instance-option.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.instance-option.instance-add {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.instance-opt-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.instance-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.instance-divider {
|
||||
height: 1px;
|
||||
background: var(--border-secondary);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-sm) var(--space-sm);
|
||||
@@ -209,6 +308,67 @@
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* 版本更新通知横幅 */
|
||||
.update-banner {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
font-size: var(--font-size-sm);
|
||||
z-index: 100;
|
||||
transition: all 300ms ease;
|
||||
overflow: hidden;
|
||||
max-height: 60px;
|
||||
}
|
||||
.update-banner-hidden {
|
||||
max-height: 0;
|
||||
padding: 0 20px;
|
||||
opacity: 0;
|
||||
}
|
||||
.update-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.update-banner-text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.update-banner-ver {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.update-banner-changelog {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.update-banner .btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.update-banner .btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.update-banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.7);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.update-banner-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Gateway 未启动引导横幅 */
|
||||
.gw-banner {
|
||||
background: var(--warning, #f59e0b);
|
||||
|
||||
@@ -824,3 +824,424 @@
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* === Docker 集群管理 === */
|
||||
|
||||
.docker-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.docker-section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 节点网格 */
|
||||
.docker-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.docker-node-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.docker-node-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
.docker-node-card.offline {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.docker-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.docker-node-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.docker-node-status.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,.4); }
|
||||
.docker-node-status.offline { background: #ef4444; }
|
||||
.docker-node-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.docker-node-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.docker-node-card.online .docker-node-badge {
|
||||
background: rgba(34,197,94,.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
.docker-node-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.docker-node-remove:hover { color: var(--error, #ef4444); }
|
||||
.docker-node-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.docker-node-error { color: var(--error, #ef4444); }
|
||||
.docker-node-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 容器表格 */
|
||||
.docker-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.docker-table-wrap::-webkit-scrollbar { display: none; }
|
||||
.docker-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.docker-table th {
|
||||
text-align: left;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
.docker-table td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.docker-table tr:last-child td { border-bottom: none; }
|
||||
.docker-table tr:hover td { background: var(--bg-card-hover); }
|
||||
.docker-ct-name {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.docker-ct-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.docker-ct-image {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docker-ct-state {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.docker-ct-state.running {
|
||||
background: rgba(34,197,94,.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
.docker-ct-state.stopped {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.docker-ct-ports {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.docker-ct-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
.btn-icon.danger:hover {
|
||||
background: rgba(239,68,68,.1);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.btn-icon:disabled { opacity: .4; pointer-events: none; }
|
||||
|
||||
/* 空状态 */
|
||||
.docker-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl) var(--space-xl);
|
||||
}
|
||||
.docker-empty-icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.docker-empty-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
.docker-empty-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.docker-empty-hint {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.docker-empty-hint code {
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.docker-empty-inline {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.docker-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
.docker-dialog {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
padding: var(--space-xl, 24px);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.35);
|
||||
}
|
||||
[data-theme="dark"] .docker-dialog {
|
||||
background: var(--bg-card, #1e1e2e);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.docker-dialog-wide {
|
||||
max-width: 720px;
|
||||
}
|
||||
.docker-dialog-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.docker-dialog-hint {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin: var(--space-md) 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.docker-dialog-hint code {
|
||||
background: var(--bg-primary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.docker-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
/* 部署模式切换 */
|
||||
.deploy-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.deploy-mode-btn {
|
||||
padding: 3px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.deploy-mode-btn.active {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||
}
|
||||
.deploy-auto-summary {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
.deploy-auto-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.deploy-auto-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.deploy-auto-item + .deploy-auto-item {
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
.deploy-auto-item span:last-child {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 节点端点预设按钮 */
|
||||
.dn-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dn-preset-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.dn-preset-btn:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
color: var(--primary, #6366f1);
|
||||
}
|
||||
.dn-preset-btn.active {
|
||||
background: var(--primary, #6366f1);
|
||||
color: #fff;
|
||||
border-color: var(--primary, #6366f1);
|
||||
}
|
||||
|
||||
/* 其他容器折叠区 */
|
||||
.docker-other-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-sm) 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.docker-other-toggle::-webkit-details-marker { display: none; }
|
||||
.docker-other-toggle::before {
|
||||
content: '▶';
|
||||
font-size: 10px;
|
||||
transition: transform 150ms;
|
||||
}
|
||||
details.docker-other-section[open] > .docker-other-toggle::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.docker-other-count {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 日志 */
|
||||
.docker-logs-content {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user