feat(openclaw): Phase 1 — server pool + agent instance deployment infrastructure
## inventory-service - New: pool_servers table (public schema, platform-admin managed) - New: PoolServer entity, PoolServerRepository, PoolServerController - CRUD endpoints at /api/v1/inventory/pool-servers - Internal /deploy-creds endpoint (x-internal-api-key protected) for SSH key retrieval - increment/decrement endpoints for capacity tracking ## agent-service - New: agent_instances table (tenant schema) - New: AgentInstance entity, AgentInstanceRepository, AgentInstanceController - New: AgentInstanceDeployService — SSH-based docker deployment - Queries pool server availability from inventory-service - AES-256 encrypts OpenClaw gateway token at rest - Allocates host ports in range 20000-29999 - Fires docker run for it0hub/openclaw-bridge:latest - Async deploy with error capture - Added ssh2 dependency for SSH execution - Added INVENTORY_SERVICE_URL, INTERNAL_API_KEY, VAULT_MASTER_KEY to docker-compose ## openclaw-bridge (new package) - packages/openclaw-bridge/ — custom Docker image - Two processes via supervisord: OpenClaw gateway + IT0 Bridge (Node.js) - IT0 Bridge exposes REST API on port 3000: GET /health, GET /status, POST /task, GET /sessions, GET /metrics - Connects to OpenClaw gateway at ws://127.0.0.1:18789 via WebSocket RPC - Sends heartbeat to IT0 agent-service every 60s - Dockerfile: multi-stage build (openclaw source + bridge TS compilation) ## Web Admin - New: /server-pool page — list/add/edit/delete pool servers with capacity bars - New: /openclaw-instances page — cross-tenant instance monitoring with status filter - Sidebar: added 服务器池 (Database icon) + OpenClaw 实例 (Boxes icon) to platform_admin nav ## Flutter App - my_agents_page: rewritten to show real AgentInstance data from /api/v1/agent/instances - Added AgentInstance model with status-driven UI (running/deploying/stopped/error) - Status badges with color coding + spinner for deploying state - Summary chips showing running vs stopped counts - api_endpoints.dart: added agentInstances endpoint ## Design docs - OPENCLAW_INTEGRATION_PLAN.md: complete architecture document with all confirmed decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29a85dfe92
commit
7d5840c245
|
|
@ -0,0 +1,221 @@
|
||||||
|
# OpenClaw 集成方案设计
|
||||||
|
|
||||||
|
> 状态: 讨论中,尚未开始实现
|
||||||
|
> 日期: 2026-03-07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、OpenClaw 是什么
|
||||||
|
|
||||||
|
- **GitHub**: https://github.com/openclaw/openclaw
|
||||||
|
- **定性**: 开源自主 AI Agent,2026年初爆火,前身为 Clawdbot/Moltbot
|
||||||
|
- **完全开源**: 有完整源码 + Dockerfile,可以 fork 定制
|
||||||
|
- **核心架构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Gateway 进程 (ws://127.0.0.1:18789)
|
||||||
|
├── CLI 客户端 ← WebSocket RPC
|
||||||
|
├── Web UI ← WebSocket RPC
|
||||||
|
├── channel 插件 ← WebSocket RPC (WhatsApp/Telegram/Slack/Discord 等)
|
||||||
|
├── native 节点 ← WebSocket RPC (macOS/iOS/Android)
|
||||||
|
└── HTTP POST /tools/invoke ← 直接调用工具
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway 是唯一的控制平面,所有子系统通过它通信,没有子系统直接互相通信。
|
||||||
|
|
||||||
|
- **卷挂载**:
|
||||||
|
- `~/.openclaw` → `/home/node/.openclaw` (配置目录)
|
||||||
|
- `~/openclaw/workspace` → `/home/node/.openclaw/workspace` (工作区/用户数据)
|
||||||
|
- **关键环境变量**:
|
||||||
|
- `OPENCLAW_GATEWAY_TOKEN` — 内部 API 认证 token
|
||||||
|
- `CLAUDE_AI_SESSION_KEY` / Anthropic API Key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、集成目标
|
||||||
|
|
||||||
|
用户在 IT0 App 里对话,iAgent 帮用户:
|
||||||
|
1. 在指定服务器上部署一个属于用户自己的真实 OpenClaw 实例
|
||||||
|
2. iAgent 可以管理(启动/停止/监控)用户的 OpenClaw 实例
|
||||||
|
3. 用户可以通过 IT0 App 直接向自己的 OpenClaw 下发任务,看到结果
|
||||||
|
4. 计费、用量、状态监控全部打通到 IT0 平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术方案
|
||||||
|
|
||||||
|
### 3.1 自定义 Docker 镜像 (openclaw-bridge)
|
||||||
|
|
||||||
|
基于 OpenClaw 官方源码 (`openclaw/openclaw`) 构建我们自己的镜像,**单容器,两进程共存**(不用 sidecar,便于多租户在同一物理服务器上并发部署):
|
||||||
|
|
||||||
|
```
|
||||||
|
容器内进程:
|
||||||
|
├── openclaw gateway (原生,端口 18789 内部,不对外暴露)
|
||||||
|
└── it0-bridge (新增 Node/NestJS 服务,端口 3000 对外)
|
||||||
|
├── 连接 OpenClaw Gateway WebSocket RPC (ws://127.0.0.1:18789)
|
||||||
|
├── 对外暴露 IT0 标准 REST API
|
||||||
|
├── 接收 iAgent 下发的任务 → 转发给 OpenClaw
|
||||||
|
├── 订阅 OpenClaw 输出 → 回传给 IT0 agent-service
|
||||||
|
└── 上报健康状态 / 用量统计
|
||||||
|
```
|
||||||
|
|
||||||
|
镜像名: `it0hub/openclaw-bridge:latest`
|
||||||
|
|
||||||
|
多租户并发部署示例(同一台物理服务器):
|
||||||
|
```bash
|
||||||
|
docker run -d --name openclaw-{userId1} -p 3101:3000 -v /data/openclaw/{userId1}:/home/node/.openclaw ...
|
||||||
|
docker run -d --name openclaw-{userId2} -p 3102:3000 -v /data/openclaw/{userId2}:/home/node/.openclaw ...
|
||||||
|
```
|
||||||
|
每个实例端口独立、数据目录隔离,iAgent 通过容器名管理生命周期。
|
||||||
|
|
||||||
|
### 3.2 部署流程
|
||||||
|
|
||||||
|
iAgent(我们的 Claude Code 智能体)通过已有的 SSH 工具执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name openclaw-{userId} \
|
||||||
|
-p {port}:3000 \
|
||||||
|
-v /data/openclaw/{userId}:/home/node/.openclaw \
|
||||||
|
-e OPENCLAW_GATEWAY_TOKEN={token} \
|
||||||
|
-e IT0_AGENT_SERVICE_URL=https://it0api.szaiai.com \
|
||||||
|
-e IT0_TENANT_ID={tenantId} \
|
||||||
|
-e IT0_INSTANCE_ID={instanceId} \
|
||||||
|
-e IT0_AUTH_TOKEN={authToken} \
|
||||||
|
-e CLAUDE_API_KEY={claudeKey} \
|
||||||
|
it0/openclaw-bridge:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 IT0 后台记录 (agent_instances 表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 记录每个用户的 OpenClaw 实例
|
||||||
|
agent_instances (
|
||||||
|
id, tenant_id, user_id,
|
||||||
|
server_host, -- 部署在哪台服务器
|
||||||
|
container_name, -- docker 容器名
|
||||||
|
port, -- 对外暴露端口
|
||||||
|
status, -- running/stopped/error
|
||||||
|
gateway_token, -- OpenClaw gateway token (加密存储)
|
||||||
|
claude_api_key, -- 用户的 Anthropic key (加密存储)
|
||||||
|
config, -- JSONB,OpenClaw 配置
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 iAgent 可执行的管理操作
|
||||||
|
|
||||||
|
通过 SSH 工具调用:
|
||||||
|
- `docker start/stop/restart openclaw-{userId}`
|
||||||
|
- `docker logs openclaw-{userId}`
|
||||||
|
- `docker stats openclaw-{userId}`
|
||||||
|
|
||||||
|
通过 IT0 Bridge REST API 调用:
|
||||||
|
- `POST /task` — 向 OpenClaw 提交任务
|
||||||
|
- `GET /status` — 查询实例运行状态
|
||||||
|
- `GET /sessions` — 查询对话历史
|
||||||
|
- `GET /metrics` — 获取用量数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已确认决策
|
||||||
|
|
||||||
|
1. **目标服务器来源** ✅
|
||||||
|
- 两种都支持:
|
||||||
|
- IT0 公司提供统一服务器池(用户购买套餐,自动分配实例)
|
||||||
|
- 用户自带服务器(用户在 IT0 中录入 SSH 凭证,iAgent 登录部署)
|
||||||
|
|
||||||
|
2. **用户访问 OpenClaw 的方式** ✅
|
||||||
|
- OpenClaw 自身支持多 channel(Telegram、WhatsApp、Line 等),用户直接用这些 channel 与自己的 OpenClaw 实例交互
|
||||||
|
- IT0 的职责是**管理层**:部署、启停、监控、保障稳定运行,而不是成为主要对话入口
|
||||||
|
- IT0 App 展示实例状态、日志、用量,并提供各 channel 的接入配置引导
|
||||||
|
|
||||||
|
3. **Claude API Key 归属** ✅
|
||||||
|
- 用户购买 IT0 套餐,IT0 统一承担 Anthropic API 费用
|
||||||
|
- IT0 的 `CLAUDE_API_KEY` 注入到每个 OpenClaw 容器,用量计入对应 tenant 的配额
|
||||||
|
|
||||||
|
4. **openclaw-bridge 镜像托管** ✅
|
||||||
|
- 同时支持两种:
|
||||||
|
- Docker Hub 公开仓库(`it0hub/openclaw-bridge:latest`)——方便外部服务器拉取
|
||||||
|
- 我们自己的私有 Registry——内网/自有服务器池使用
|
||||||
|
- 镜像本身不含任何密钥,所有凭证通过环境变量运行时注入
|
||||||
|
|
||||||
|
5. **OpenClaw 版本更新策略** ✅
|
||||||
|
- 不存在复杂的版本跟踪问题:OpenClaw 发布新版时,我们拉取最新源码重新 build 一个包含 IT0 Bridge 的新镜像即可
|
||||||
|
- CI/CD: 监听 openclaw/openclaw 上游 release → 自动触发我们的镜像 build → 推送到 Docker Hub + 私有 Registry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构补充:服务器池
|
||||||
|
|
||||||
|
### 5.1 归属
|
||||||
|
- **inventory-service** 新增 `PoolServer` 实体(平台级,public schema,仅 platform_admin 可管理)
|
||||||
|
|
||||||
|
### 5.2 数据结构
|
||||||
|
```sql
|
||||||
|
-- public schema(非租户隔离)
|
||||||
|
CREATE TABLE pool_servers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL, -- 别名,如"训练服务器"
|
||||||
|
host VARCHAR(255) NOT NULL, -- IP 或域名
|
||||||
|
ssh_port INT NOT NULL DEFAULT 22,
|
||||||
|
ssh_user VARCHAR(100) NOT NULL,
|
||||||
|
ssh_key TEXT NOT NULL, -- 加密存储的私钥内容
|
||||||
|
max_instances INT NOT NULL DEFAULT 10, -- 管理员手动设置上限
|
||||||
|
status VARCHAR(20) DEFAULT 'active', -- active / maintenance / offline
|
||||||
|
region VARCHAR(100), -- 可选,地区标注
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 SSH 凭证存储
|
||||||
|
- SSH 私钥加密后存入 `pool_servers.ssh_key`(AES-256,密钥来自 agent-service 环境变量)
|
||||||
|
- agent-service 在部署时解密私钥,写入临时文件,SSH 连接后立即删除临时文件
|
||||||
|
|
||||||
|
### 5.4 容量分配逻辑
|
||||||
|
```
|
||||||
|
iAgent 部署新实例时:
|
||||||
|
1. 查询 inventory-service: GET /api/v1/inventory/pool-servers?available=true
|
||||||
|
2. 返回 current_instances < max_instances 的服务器列表
|
||||||
|
3. 选用量最少的那台(最优分布)
|
||||||
|
4. SSH 连接 → docker run → 成功后 current_instances + 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实现阶段规划(已确认)
|
||||||
|
|
||||||
|
### Phase 1: 服务器池 + 基础部署能力
|
||||||
|
|
||||||
|
**inventory-service**
|
||||||
|
- [ ] DB 迁移: `pool_servers` 表(public schema)
|
||||||
|
- [ ] PoolServer 实体 + Repository + CRUD REST 接口
|
||||||
|
- [ ] Kong 路由: `/api/v1/inventory/pool-servers`(platform_admin only)
|
||||||
|
|
||||||
|
**agent-service**
|
||||||
|
- [ ] DB 迁移: `agent_instances` 表(tenant schema)
|
||||||
|
- [ ] AgentInstance 实体 + Repository + CRUD REST 接口
|
||||||
|
- [ ] SSH 私钥加解密工具(AES-256)
|
||||||
|
- [ ] 部署工具: 查询服务器池 → SSH → docker run → 记录实例
|
||||||
|
|
||||||
|
**openclaw-bridge 镜像**
|
||||||
|
- [ ] 基于 openclaw/openclaw 源码,新建 `packages/openclaw-bridge/` 目录
|
||||||
|
- [ ] 编写 it0-bridge 进程(Node.js,连接 OpenClaw Gateway WS 18789,对外 REST 3000)
|
||||||
|
- [ ] 编写 Dockerfile(两进程,supervisord 管理)
|
||||||
|
- [ ] 推送到 Docker Hub: `it0hub/openclaw-bridge:latest`
|
||||||
|
|
||||||
|
**Web Admin**
|
||||||
|
- [ ] 服务器池管理页面 `/server-pool`(列表 + 添加 + 删除 + 容量编辑)
|
||||||
|
- [ ] OpenClaw 实例列表页 `/openclaw-instances`(跨租户,platform_admin 视角)
|
||||||
|
|
||||||
|
### Phase 2: App 集成
|
||||||
|
- [ ] my_agents_page 展示真实实例列表 + 状态
|
||||||
|
- [ ] 实例详情页: 状态 / 日志 / channel 配置引导(Telegram/WhatsApp/Line 接入)
|
||||||
|
- [ ] iAgent 对话支持部署指令识别("帮我创建一个 OpenClaw")
|
||||||
|
|
||||||
|
### Phase 3: 计费与监控
|
||||||
|
- [ ] 用量上报到 billing-service(每个实例的 token 用量)
|
||||||
|
- [ ] 套餐限制(免费版: 0个实例,Pro: 1个,Enterprise: N个)
|
||||||
|
- [ ] 实例健康监控 + 自动重启(心跳检测)
|
||||||
|
|
@ -140,6 +140,9 @@ services:
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
||||||
- AGENT_SERVICE_PORT=3002
|
- AGENT_SERVICE_PORT=3002
|
||||||
|
- INVENTORY_SERVICE_URL=http://inventory-service:3004
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key}
|
||||||
|
- VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
@ -206,6 +209,7 @@ services:
|
||||||
- DB_DATABASE=${POSTGRES_DB:-it0}
|
- DB_DATABASE=${POSTGRES_DB:-it0}
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key}
|
- VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key}
|
||||||
- INVENTORY_SERVICE_PORT=3004
|
- INVENTORY_SERVICE_PORT=3004
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3004/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3004/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { RefreshCw, Boxes, CheckCircle, AlertCircle, Clock, XCircle, StopCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgentInstance {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
agentType: string;
|
||||||
|
poolServerId?: string;
|
||||||
|
serverHost: string;
|
||||||
|
hostPort: number;
|
||||||
|
containerName: string;
|
||||||
|
status: 'deploying' | 'running' | 'stopped' | 'error' | 'removed';
|
||||||
|
errorMessage?: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
hasToken: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/proxy/api/v1/agent/instances';
|
||||||
|
|
||||||
|
async function fetchInstances(): Promise<AgentInstance[]> {
|
||||||
|
const res = await fetch(API);
|
||||||
|
if (!res.ok) throw new Error('Failed to load instances');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopInstance(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/${id}/stop`, { method: 'PUT' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any).message ?? 'Failed to stop instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeInstance(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any).message ?? 'Failed to remove instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
deploying: { icon: <Clock className="w-4 h-4 text-blue-400" />, label: '部署中', color: 'text-blue-400' },
|
||||||
|
running: { icon: <CheckCircle className="w-4 h-4 text-green-500" />, label: '运行中', color: 'text-green-500' },
|
||||||
|
stopped: { icon: <StopCircle className="w-4 h-4 text-yellow-500" />, label: '已停止', color: 'text-yellow-500' },
|
||||||
|
error: { icon: <AlertCircle className="w-4 h-4 text-red-500" />, label: '错误', color: 'text-red-500' },
|
||||||
|
removed: { icon: <XCircle className="w-4 h-4 text-muted-foreground" />, label: '已移除', color: 'text-muted-foreground' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: AgentInstance['status'] }) {
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs font-medium ${cfg.color}`}>
|
||||||
|
{cfg.icon}
|
||||||
|
{cfg.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OpenClawInstancesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [statusFilter, setStatusFilter] = useState<AgentInstance['status'] | 'all'>('all');
|
||||||
|
|
||||||
|
const { data: instances = [], isLoading, error, refetch } = useQuery<AgentInstance[]>({
|
||||||
|
queryKey: ['openclaw-instances'],
|
||||||
|
queryFn: fetchInstances,
|
||||||
|
refetchInterval: 15_000, // Auto-refresh every 15s to catch status changes
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = statusFilter === 'all'
|
||||||
|
? instances
|
||||||
|
: instances.filter((i) => i.status === statusFilter);
|
||||||
|
|
||||||
|
const counts = instances.reduce((acc, i) => {
|
||||||
|
acc[i.status] = (acc[i.status] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
const handleStop = async (inst: AgentInstance) => {
|
||||||
|
if (!confirm(`确定要停止实例「${inst.name}」吗?`)) return;
|
||||||
|
try {
|
||||||
|
await stopInstance(inst.id);
|
||||||
|
toast.success('实例已停止');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (inst: AgentInstance) => {
|
||||||
|
if (!confirm(`确定要移除实例「${inst.name}」吗?容器将被销毁,数据卷保留。`)) return;
|
||||||
|
try {
|
||||||
|
await removeInstance(inst.id);
|
||||||
|
toast.success('实例已移除');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : '移除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_TABS: { value: AgentInstance['status'] | 'all'; label: string }[] = [
|
||||||
|
{ value: 'all', label: `全部 (${instances.length})` },
|
||||||
|
{ value: 'running', label: `运行中 (${counts.running ?? 0})` },
|
||||||
|
{ value: 'deploying', label: `部署中 (${counts.deploying ?? 0})` },
|
||||||
|
{ value: 'stopped', label: `已停止 (${counts.stopped ?? 0})` },
|
||||||
|
{ value: 'error', label: `错误 (${counts.error ?? 0})` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">OpenClaw 实例</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
跨租户查看所有用户的 OpenClaw 智能体实例
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-accent text-foreground hover:bg-accent/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter tabs */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setStatusFilter(value)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-accent text-muted-foreground hover:text-foreground hover:bg-accent/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 text-red-400 text-sm">
|
||||||
|
加载失败:{error instanceof Error ? error.message : String(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="bg-card rounded-lg border p-5 animate-pulse h-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instance list */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="bg-card rounded-lg border p-12 text-center">
|
||||||
|
<Boxes className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground text-sm">暂无实例数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((inst) => (
|
||||||
|
<div key={inst.id} className={`bg-card rounded-lg border p-5 ${inst.status === 'removed' ? 'opacity-50' : ''}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1.5 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<StatusBadge status={inst.status} />
|
||||||
|
<span className="font-medium text-sm">{inst.name}</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] bg-accent text-muted-foreground uppercase">{inst.agentType}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
<span>容器: <code className="text-foreground">{inst.containerName}</code></span>
|
||||||
|
<span>地址: <code className="text-foreground">{inst.serverHost}:{inst.hostPort}</code></span>
|
||||||
|
<span>用户ID: <code className="text-foreground">{inst.userId.slice(0, 8)}…</code></span>
|
||||||
|
<span>创建: {formatDate(inst.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{inst.errorMessage && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-500/10 rounded px-2 py-1 mt-1">
|
||||||
|
{inst.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{inst.status !== 'removed' && (
|
||||||
|
<div className="flex items-center gap-1.5 ml-4 shrink-0">
|
||||||
|
{inst.status === 'running' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStop(inst)}
|
||||||
|
className="px-2.5 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(inst.status === 'stopped' || inst.status === 'error') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(inst)}
|
||||||
|
className="px-2.5 py-1.5 rounded text-xs bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { RefreshCw, Plus, Trash2, Pencil, Server, CheckCircle, AlertCircle, WrenchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PoolServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
sshUser: string;
|
||||||
|
maxInstances: number;
|
||||||
|
currentInstances: number;
|
||||||
|
status: 'active' | 'maintenance' | 'offline';
|
||||||
|
region?: string;
|
||||||
|
notes?: string;
|
||||||
|
hasKey: boolean;
|
||||||
|
available: boolean;
|
||||||
|
freeSlots: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/proxy/api/v1/inventory/pool-servers';
|
||||||
|
|
||||||
|
async function fetchServers(): Promise<PoolServer[]> {
|
||||||
|
const res = await fetch(API);
|
||||||
|
if (!res.ok) throw new Error('Failed to load pool servers');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServer(data: Record<string, unknown>): Promise<PoolServer> {
|
||||||
|
const res = await fetch(API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message ?? 'Failed to create server');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateServer(id: string, data: Record<string, unknown>): Promise<PoolServer> {
|
||||||
|
const res = await fetch(`${API}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message ?? 'Failed to update server');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message ?? 'Failed to delete server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICONS = {
|
||||||
|
active: <CheckCircle className="w-4 h-4 text-green-500" />,
|
||||||
|
maintenance: <WrenchIcon className="w-4 h-4 text-yellow-500" />,
|
||||||
|
offline: <AlertCircle className="w-4 h-4 text-red-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS = { active: '运行中', maintenance: '维护中', offline: '离线' };
|
||||||
|
|
||||||
|
// ── Add/Edit Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ServerModal({
|
||||||
|
server,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
server?: PoolServer;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: Record<string, unknown>) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: server?.name ?? '',
|
||||||
|
host: server?.host ?? '',
|
||||||
|
sshPort: server?.sshPort ?? 22,
|
||||||
|
sshUser: server?.sshUser ?? '',
|
||||||
|
sshKey: '',
|
||||||
|
maxInstances: server?.maxInstances ?? 10,
|
||||||
|
status: server?.status ?? 'active',
|
||||||
|
region: server?.region ?? '',
|
||||||
|
notes: server?.notes ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = { ...form };
|
||||||
|
if (!form.sshKey) delete payload.sshKey; // Don't send empty key on edit
|
||||||
|
await onSave(payload);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = 'w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary';
|
||||||
|
const labelClass = 'block text-xs text-muted-foreground mb-1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||||
|
<h2 className="text-base font-semibold">{server ? '编辑服务器' : '添加服务器'}</h2>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>名称 *</label>
|
||||||
|
<input required value={form.name} onChange={set('name')} placeholder="训练服务器" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>地区</label>
|
||||||
|
<input value={form.region} onChange={set('region')} placeholder="深圳" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={labelClass}>主机 IP / 域名 *</label>
|
||||||
|
<input required value={form.host} onChange={set('host')} placeholder="89.185.24.182" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>SSH 端口</label>
|
||||||
|
<input type="number" value={form.sshPort} onChange={set('sshPort')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>SSH 用户名 *</label>
|
||||||
|
<input required value={form.sshUser} onChange={set('sshUser')} placeholder="ceshi" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>SSH 私钥 {server ? '(留空保留原有密钥)' : '*'}</label>
|
||||||
|
<textarea
|
||||||
|
required={!server}
|
||||||
|
value={form.sshKey}
|
||||||
|
onChange={set('sshKey')}
|
||||||
|
rows={5}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..."
|
||||||
|
className={`${inputClass} font-mono text-xs resize-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>最大实例数</label>
|
||||||
|
<input type="number" min={1} max={500} value={form.maxInstances} onChange={set('maxInstances')} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>状态</label>
|
||||||
|
<select value={form.status} onChange={set('status')} className={inputClass}>
|
||||||
|
<option value="active">运行中</option>
|
||||||
|
<option value="maintenance">维护中</option>
|
||||||
|
<option value="offline">离线</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>备注</label>
|
||||||
|
<textarea value={form.notes} onChange={set('notes')} rows={2} className={`${inputClass} resize-none`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 rounded-md text-sm bg-accent text-foreground hover:bg-accent/80">取消</button>
|
||||||
|
<button type="submit" disabled={saving} className="px-4 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ServerPoolPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<PoolServer | undefined>();
|
||||||
|
|
||||||
|
const { data: servers = [], isLoading, error, refetch } = useQuery<PoolServer[]>({
|
||||||
|
queryKey: ['pool-servers'],
|
||||||
|
queryFn: fetchServers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async (data: Record<string, unknown>) => {
|
||||||
|
if (editingServer) {
|
||||||
|
await updateServer(editingServer.id, data);
|
||||||
|
toast.success('服务器已更新');
|
||||||
|
} else {
|
||||||
|
await createServer(data);
|
||||||
|
toast.success('服务器已添加');
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pool-servers'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (server: PoolServer) => {
|
||||||
|
if (!confirm(`确定要删除服务器「${server.name}」吗?\n当前有 ${server.currentInstances} 个活跃实例时无法删除。`)) return;
|
||||||
|
try {
|
||||||
|
await deleteServer(server.id);
|
||||||
|
toast.success('服务器已删除');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pool-servers'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCapacity = servers.reduce((a, s) => a + s.maxInstances, 0);
|
||||||
|
const totalUsed = servers.reduce((a, s) => a + s.currentInstances, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">服务器池管理</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
管理用于部署 OpenClaw 实例的物理/云服务器资源池
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => refetch()} disabled={isLoading} className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-accent text-foreground hover:bg-accent/80 disabled:opacity-50">
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditingServer(undefined); setShowModal(true); }} className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
<Plus className="w-4 h-4" /> 添加服务器
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
{servers.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: '服务器总数', value: servers.length },
|
||||||
|
{ label: '总容量(实例)', value: totalCapacity },
|
||||||
|
{ label: '已用 / 空闲', value: `${totalUsed} / ${totalCapacity - totalUsed}` },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="bg-card rounded-lg border p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-2xl font-semibold mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 text-red-400 text-sm">
|
||||||
|
加载失败:{error instanceof Error ? error.message : String(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="bg-card rounded-lg border p-5 animate-pulse h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server list */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<div className="bg-card rounded-lg border p-12 text-center">
|
||||||
|
<Server className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground text-sm mb-4">暂无服务器,添加第一台开始部署 OpenClaw</p>
|
||||||
|
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
添加服务器
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
servers.map((server) => (
|
||||||
|
<div key={server.id} className="bg-card rounded-lg border p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{STATUS_ICONS[server.status]}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{server.name}</span>
|
||||||
|
{server.region && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs bg-accent text-muted-foreground">{server.region}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">{STATUS_LABELS[server.status]}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{server.sshUser}@{server.host}:{server.sshPort}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => { setEditingServer(server); setShowModal(true); }} className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(server)} className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-red-400">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>实例用量</span>
|
||||||
|
<span>{server.currentInstances} / {server.maxInstances}(剩余 {server.freeSlots} 个)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-accent overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
server.currentInstances / server.maxInstances > 0.8 ? 'bg-red-500' :
|
||||||
|
server.currentInstances / server.maxInstances > 0.5 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(100, (server.currentInstances / server.maxInstances) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{server.notes && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-3 border-t pt-2">{server.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ServerModal
|
||||||
|
server={editingServer}
|
||||||
|
onClose={() => { setShowModal(false); setEditingServer(undefined); }}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
|
Database,
|
||||||
|
Boxes,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/* ---------- Sidebar context for collapse state ---------- */
|
/* ---------- Sidebar context for collapse state ---------- */
|
||||||
|
|
@ -108,6 +110,8 @@ export function Sidebar() {
|
||||||
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
|
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
|
||||||
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
|
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
|
||||||
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
|
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
|
||||||
|
{ key: 'serverPool', label: '服务器池', href: '/server-pool', icon: <Database className={iconClass} /> },
|
||||||
|
{ key: 'openclawInstances', label: 'OpenClaw 实例', href: '/openclaw-instances', icon: <Boxes className={iconClass} /> },
|
||||||
{
|
{
|
||||||
key: 'billing',
|
key: 'billing',
|
||||||
label: t('billing'),
|
label: t('billing'),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class ApiEndpoints {
|
||||||
static const String sessions = '$agent/sessions';
|
static const String sessions = '$agent/sessions';
|
||||||
static const String engines = '$agent/engines';
|
static const String engines = '$agent/engines';
|
||||||
static const String agentConfigs = '$agent/configs';
|
static const String agentConfigs = '$agent/configs';
|
||||||
|
static const String agentInstances = '$agent/instances';
|
||||||
|
|
||||||
// Ops
|
// Ops
|
||||||
static const String opsTasks = '$ops/tasks';
|
static const String opsTasks = '$ops/tasks';
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,63 @@ import '../../../../core/config/api_endpoints.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
import '../../../../core/utils/date_formatter.dart';
|
import '../../../../core/utils/date_formatter.dart';
|
||||||
import '../../../../core/widgets/empty_state.dart';
|
|
||||||
import '../../../../core/widgets/error_view.dart';
|
import '../../../../core/widgets/error_view.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider: fetches agent configs (user-created + official)
|
// Model
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
final myAgentsProvider =
|
class AgentInstance {
|
||||||
FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String agentType;
|
||||||
|
final String serverHost;
|
||||||
|
final int hostPort;
|
||||||
|
final String containerName;
|
||||||
|
final String status; // deploying | running | stopped | error | removed
|
||||||
|
final String? errorMessage;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const AgentInstance({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.agentType,
|
||||||
|
required this.serverHost,
|
||||||
|
required this.hostPort,
|
||||||
|
required this.containerName,
|
||||||
|
required this.status,
|
||||||
|
this.errorMessage,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AgentInstance.fromJson(Map<String, dynamic> j) => AgentInstance(
|
||||||
|
id: j['id'] as String,
|
||||||
|
name: j['name'] as String? ?? '未命名',
|
||||||
|
agentType: j['agentType'] as String? ?? 'openclaw',
|
||||||
|
serverHost: j['serverHost'] as String? ?? '',
|
||||||
|
hostPort: j['hostPort'] as int? ?? 0,
|
||||||
|
containerName: j['containerName'] as String? ?? '',
|
||||||
|
status: j['status'] as String? ?? 'unknown',
|
||||||
|
errorMessage: j['errorMessage'] as String?,
|
||||||
|
createdAt: DateTime.tryParse(j['createdAt'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
final myInstancesProvider = FutureProvider<List<AgentInstance>>((ref) async {
|
||||||
final dio = ref.watch(dioClientProvider);
|
final dio = ref.watch(dioClientProvider);
|
||||||
try {
|
try {
|
||||||
final response = await dio.get(ApiEndpoints.agentConfigs);
|
final res = await dio.get(ApiEndpoints.agentInstances);
|
||||||
final data = response.data;
|
final data = res.data;
|
||||||
if (data is List) return data.cast<Map<String, dynamic>>();
|
if (data is List) {
|
||||||
if (data is Map && data.containsKey('items')) {
|
return data
|
||||||
return (data['items'] as List).cast<Map<String, dynamic>>();
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(AgentInstance.fromJson)
|
||||||
|
.where((i) => i.status != 'removed')
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -28,7 +69,32 @@ final myAgentsProvider =
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// My Agents Page — "我的创建" Tab
|
// Status helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _statusColors = {
|
||||||
|
'running': Color(0xFF22C55E),
|
||||||
|
'deploying': Color(0xFF3B82F6),
|
||||||
|
'stopped': Color(0xFFF59E0B),
|
||||||
|
'error': Color(0xFFEF4444),
|
||||||
|
};
|
||||||
|
|
||||||
|
const _statusLabels = {
|
||||||
|
'running': '运行中',
|
||||||
|
'deploying': '部署中',
|
||||||
|
'stopped': '已停止',
|
||||||
|
'error': '错误',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _statusIcons = {
|
||||||
|
'running': Icons.circle,
|
||||||
|
'deploying': Icons.sync,
|
||||||
|
'stopped': Icons.pause_circle_outline,
|
||||||
|
'error': Icons.error_outline,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// My Agents Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class MyAgentsPage extends ConsumerWidget {
|
class MyAgentsPage extends ConsumerWidget {
|
||||||
|
|
@ -36,7 +102,7 @@ class MyAgentsPage extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final agentsAsync = ref.watch(myAgentsProvider);
|
final instancesAsync = ref.watch(myInstancesProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
|
|
@ -46,19 +112,18 @@ class MyAgentsPage extends ConsumerWidget {
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh_outlined),
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
onPressed: () => ref.invalidate(myAgentsProvider),
|
onPressed: () => ref.invalidate(myInstancesProvider),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: agentsAsync.when(
|
body: instancesAsync.when(
|
||||||
data: (agents) => agents.isEmpty
|
data: (instances) => instances.isEmpty
|
||||||
? _buildEmptyGuide(context)
|
? _buildEmptyGuide(context)
|
||||||
: _buildAgentList(context, ref, agents),
|
: _buildInstanceList(context, ref, instances),
|
||||||
loading: () =>
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (e, _) => ErrorView(
|
error: (e, _) => ErrorView(
|
||||||
error: e,
|
error: e,
|
||||||
onRetry: () => ref.invalidate(myAgentsProvider),
|
onRetry: () => ref.invalidate(myInstancesProvider),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -70,7 +135,6 @@ class MyAgentsPage extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
// Main illustration
|
|
||||||
Container(
|
Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
|
|
@ -78,58 +142,25 @@ class MyAgentsPage extends ConsumerWidget {
|
||||||
color: AppColors.primary.withOpacity(0.1),
|
color: AppColors.primary.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.smart_toy_outlined, size: 60, color: AppColors.primary),
|
||||||
Icons.smart_toy_outlined,
|
|
||||||
size: 60,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
'创建你的专属智能体',
|
'创建你的专属智能体',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const Text(
|
const Text(
|
||||||
'通过与 iAgent 对话,你可以创建各种智能体:\nOpenClaw 编程助手、运维机器人、数据分析师...',
|
'通过与 iAgent 对话,你可以创建各种智能体:\nOpenClaw 编程助手、运维机器人、数据分析师...',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.6),
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
height: 1.6,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
|
_StepCard(step: '1', title: '点击下方机器人', desc: '打开与 iAgent 的对话窗口', icon: Icons.smart_toy_outlined, color: AppColors.primary),
|
||||||
// Step cards
|
|
||||||
_StepCard(
|
|
||||||
step: '1',
|
|
||||||
title: '点击下方机器人',
|
|
||||||
desc: '打开与 iAgent 的对话窗口',
|
|
||||||
icon: Icons.smart_toy_outlined,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StepCard(
|
_StepCard(step: '2', title: '描述你想要的智能体', desc: '例如:"帮我创建一个 OpenClaw 编程助手"', icon: Icons.record_voice_over_outlined, color: const Color(0xFF0EA5E9)),
|
||||||
step: '2',
|
|
||||||
title: '描述你想要的智能体',
|
|
||||||
desc: '例如:"帮我创建一个监控 GitHub Actions 的 OpenClaw 智能体"',
|
|
||||||
icon: Icons.record_voice_over_outlined,
|
|
||||||
color: const Color(0xFF0EA5E9),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StepCard(
|
_StepCard(step: '3', title: 'iAgent 自动部署', desc: '部署完成后出现在这里,通过 Telegram/WhatsApp 等渠道与它对话', icon: Icons.check_circle_outline, color: AppColors.success),
|
||||||
step: '3',
|
|
||||||
title: 'iAgent 自动完成配置',
|
|
||||||
desc: '智能体会出现在这里,随时可以与它对话',
|
|
||||||
icon: Icons.check_circle_outline,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
const _TemplatesSection(),
|
const _TemplatesSection(),
|
||||||
const SizedBox(height: 100),
|
const SizedBox(height: 100),
|
||||||
|
|
@ -138,18 +169,198 @@ class MyAgentsPage extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAgentList(
|
Widget _buildInstanceList(BuildContext context, WidgetRef ref, List<AgentInstance> instances) {
|
||||||
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> agents) {
|
final running = instances.where((i) => i.status == 'running').length;
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async => ref.invalidate(myAgentsProvider),
|
onRefresh: () async => ref.invalidate(myInstancesProvider),
|
||||||
child: ListView.separated(
|
child: CustomScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
slivers: [
|
||||||
itemCount: agents.length,
|
SliverToBoxAdapter(
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
child: Padding(
|
||||||
itemBuilder: (context, index) {
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
final agent = agents[index];
|
child: Row(
|
||||||
return _AgentListCard(agent: agent);
|
children: [
|
||||||
},
|
_SummaryChip(label: '总计 ${instances.length}', color: AppColors.textMuted),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_SummaryChip(label: '运行中 $running', color: const Color(0xFF22C55E)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_SummaryChip(label: '已停止 ${instances.length - running}', color: const Color(0xFFF59E0B)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 12)),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
if (index.isOdd) return const SizedBox(height: 10);
|
||||||
|
return _InstanceCard(instance: instances[index ~/ 2]);
|
||||||
|
},
|
||||||
|
childCount: instances.length * 2 - 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary chip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _SummaryChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _SummaryChip({required this.label, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Instance card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _InstanceCard extends StatelessWidget {
|
||||||
|
final AgentInstance instance;
|
||||||
|
|
||||||
|
const _InstanceCard({required this.instance});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final statusColor = _statusColors[instance.status] ?? AppColors.textMuted;
|
||||||
|
final statusLabel = _statusLabels[instance.status] ?? instance.status;
|
||||||
|
final statusIcon = _statusIcons[instance.status] ?? Icons.help_outline;
|
||||||
|
final timeLabel = DateFormatter.timeAgo(instance.createdAt);
|
||||||
|
final isDeploying = instance.status == 'deploying';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: statusColor.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.hub_outlined, color: AppColors.primary, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
instance.name,
|
||||||
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
instance.agentType.toUpperCase(),
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.textMuted, letterSpacing: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Status badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
isDeploying
|
||||||
|
? SizedBox(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 1.5, color: statusColor),
|
||||||
|
)
|
||||||
|
: Icon(statusIcon, size: 10, color: statusColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(statusLabel, style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Server info row
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: AppColors.background, borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.dns_outlined, size: 14, color: AppColors.textMuted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${instance.serverHost}:${instance.hostPort}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontFamily: 'monospace'),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(timeLabel, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if (instance.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEF4444).withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber_outlined, size: 14, color: Color(0xFFEF4444)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
instance.errorMessage!,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFFEF4444)),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +377,7 @@ class _StepCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
const _StepCard({
|
const _StepCard({required this.step, required this.title, required this.desc, required this.icon, required this.color});
|
||||||
required this.step,
|
|
||||||
required this.title,
|
|
||||||
required this.desc,
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -185,47 +390,20 @@ class _StepCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Step number circle
|
|
||||||
Container(
|
Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: color.withOpacity(0.15), shape: BoxShape.circle),
|
||||||
color: color.withOpacity(0.15),
|
child: Center(child: Text(step, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14))),
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
step,
|
|
||||||
style: TextStyle(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(desc, style: const TextStyle(fontSize: 12, color: AppColors.textMuted, height: 1.4)),
|
||||||
desc,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -237,37 +415,17 @@ class _StepCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Template suggestions section
|
// Template suggestions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _TemplatesSection extends StatelessWidget {
|
class _TemplatesSection extends StatelessWidget {
|
||||||
const _TemplatesSection();
|
const _TemplatesSection();
|
||||||
|
|
||||||
static const _templates = [
|
static const _templates = [
|
||||||
_Template(
|
_Template(name: 'OpenClaw 编程助手', desc: '代码审查、自动化测试', icon: Icons.code_outlined, color: Color(0xFF8B5CF6)),
|
||||||
name: 'OpenClaw 编程助手',
|
_Template(name: '运维自动化机器人', desc: '日志分析、故障自愈', icon: Icons.auto_fix_high_outlined, color: Color(0xFFF59E0B)),
|
||||||
desc: '代码审查、自动化测试、CI/CD 管理',
|
_Template(name: '数据分析助手', desc: '报表生成、异常检测', icon: Icons.bar_chart_outlined, color: Color(0xFF0EA5E9)),
|
||||||
icon: Icons.code_outlined,
|
_Template(name: '安全巡检机器人', desc: '漏洞扫描、合规审查', icon: Icons.shield_outlined, color: Color(0xFFEF4444)),
|
||||||
color: Color(0xFF8B5CF6),
|
|
||||||
),
|
|
||||||
_Template(
|
|
||||||
name: '运维自动化机器人',
|
|
||||||
desc: '日志分析、故障自动恢复、扩缩容',
|
|
||||||
icon: Icons.auto_fix_high_outlined,
|
|
||||||
color: Color(0xFFF59E0B),
|
|
||||||
),
|
|
||||||
_Template(
|
|
||||||
name: '数据分析助手',
|
|
||||||
desc: '报表生成、异常检测、趋势预测',
|
|
||||||
icon: Icons.bar_chart_outlined,
|
|
||||||
color: Color(0xFF0EA5E9),
|
|
||||||
),
|
|
||||||
_Template(
|
|
||||||
name: '安全巡检机器人',
|
|
||||||
desc: '漏洞扫描、入侵检测、合规审查',
|
|
||||||
icon: Icons.shield_outlined,
|
|
||||||
color: Color(0xFFEF4444),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -275,14 +433,7 @@ class _TemplatesSection extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text('热门模板(告诉 iAgent 你想要哪种)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
|
||||||
'热门模板(告诉 iAgent 你想要哪种)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
GridView.count(
|
GridView.count(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
|
|
@ -291,9 +442,7 @@ class _TemplatesSection extends StatelessWidget {
|
||||||
mainAxisSpacing: 10,
|
mainAxisSpacing: 10,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 10,
|
||||||
childAspectRatio: 1.5,
|
childAspectRatio: 1.5,
|
||||||
children: _templates
|
children: _templates.map((t) => _TemplateChip(template: t)).toList(),
|
||||||
.map((t) => _TemplateChip(template: t))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -305,18 +454,11 @@ class _Template {
|
||||||
final String desc;
|
final String desc;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
const _Template({required this.name, required this.desc, required this.icon, required this.color});
|
||||||
const _Template({
|
|
||||||
required this.name,
|
|
||||||
required this.desc,
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TemplateChip extends StatelessWidget {
|
class _TemplateChip extends StatelessWidget {
|
||||||
final _Template template;
|
final _Template template;
|
||||||
|
|
||||||
const _TemplateChip({required this.template});
|
const _TemplateChip({required this.template});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -326,8 +468,7 @@ class _TemplateChip extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border:
|
border: Border.all(color: template.color.withOpacity(0.25)),
|
||||||
Border.all(color: template.color.withOpacity(0.25)),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -337,26 +478,9 @@ class _TemplateChip extends StatelessWidget {
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(template.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textPrimary), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
template.name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(template.desc, style: const TextStyle(fontSize: 10, color: AppColors.textMuted), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
template.desc,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -364,124 +488,3 @@ class _TemplateChip extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agent list card (when user has created agents)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _AgentListCard extends StatelessWidget {
|
|
||||||
final Map<String, dynamic> agent;
|
|
||||||
|
|
||||||
const _AgentListCard({required this.agent});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final name = agent['name'] as String? ?? agent['agentName'] as String? ?? '未命名智能体';
|
|
||||||
final desc = agent['description'] as String? ?? '';
|
|
||||||
final engineType = agent['engineType'] as String? ?? '';
|
|
||||||
final createdAt = agent['createdAt'] as String? ?? agent['created_at'] as String?;
|
|
||||||
final timeLabel = createdAt != null
|
|
||||||
? DateFormatter.timeAgo(DateTime.parse(createdAt))
|
|
||||||
: '';
|
|
||||||
|
|
||||||
final typeLabel = switch (engineType) {
|
|
||||||
'claude_agent_sdk' => 'Agent SDK',
|
|
||||||
'claude_api' => 'Claude API',
|
|
||||||
_ => engineType.isNotEmpty ? engineType : 'iAgent',
|
|
||||||
};
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.primary.withOpacity(0.15),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primary.withOpacity(0.12),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.smart_toy_outlined,
|
|
||||||
color: AppColors.primary,
|
|
||||||
size: 26,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (desc.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
desc,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 7, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primary.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
typeLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (timeLabel.isNotEmpty) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
timeLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# ============================================================
|
||||||
|
# IT0 OpenClaw Bridge Image
|
||||||
|
# Based on the official openclaw/openclaw source.
|
||||||
|
# Adds an IT0 Bridge process (Node.js, port 3000) that connects
|
||||||
|
# to the OpenClaw gateway (ws://127.0.0.1:18789) and exposes
|
||||||
|
# a REST management API for IT0 agent-service.
|
||||||
|
#
|
||||||
|
# Two processes managed by supervisord:
|
||||||
|
# 1. openclaw — the original OpenClaw gateway + CLI
|
||||||
|
# 2. it0-bridge — this package's compiled Node.js server
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ── Stage 1: Build OpenClaw from source ──────────────────────
|
||||||
|
FROM node:20-slim AS openclaw-builder
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@latest
|
||||||
|
|
||||||
|
WORKDIR /build/openclaw
|
||||||
|
# Clone the latest openclaw source
|
||||||
|
RUN apt-get update && apt-get install -y git && \
|
||||||
|
git clone --depth 1 https://github.com/openclaw/openclaw.git . && \
|
||||||
|
pnpm install --frozen-lockfile && \
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# ── Stage 2: Build IT0 Bridge ─────────────────────────────────
|
||||||
|
FROM node:20-slim AS bridge-builder
|
||||||
|
|
||||||
|
WORKDIR /build/bridge
|
||||||
|
COPY package.json tsconfig.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 3: Final image ──────────────────────────────────────
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user (matching openclaw's expected uid)
|
||||||
|
RUN useradd -m -u 1000 node 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── OpenClaw files ────────────────────────────────────────────
|
||||||
|
WORKDIR /app/openclaw
|
||||||
|
COPY --from=openclaw-builder --chown=node:node /build/openclaw/dist ./dist
|
||||||
|
COPY --from=openclaw-builder --chown=node:node /build/openclaw/node_modules ./node_modules
|
||||||
|
COPY --from=openclaw-builder --chown=node:node /build/openclaw/package.json ./
|
||||||
|
|
||||||
|
# ── IT0 Bridge files ──────────────────────────────────────────
|
||||||
|
WORKDIR /app/bridge
|
||||||
|
COPY --from=bridge-builder --chown=node:node /build/bridge/dist ./dist
|
||||||
|
COPY --from=bridge-builder --chown=node:node /build/bridge/node_modules ./node_modules
|
||||||
|
COPY --from=bridge-builder --chown=node:node /build/bridge/package.json ./
|
||||||
|
|
||||||
|
# ── supervisord config ────────────────────────────────────────
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/openclaw-bridge.conf
|
||||||
|
|
||||||
|
# ── Data directory for openclaw config + workspace ───────────
|
||||||
|
RUN mkdir -p /home/node/.openclaw/workspace && \
|
||||||
|
chown -R node:node /home/node/.openclaw
|
||||||
|
|
||||||
|
# Expose only the IT0 Bridge port (OpenClaw gateway 18789 is internal only)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/openclaw-bridge.conf"]
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# IT0 OpenClaw Bridge
|
||||||
|
|
||||||
|
Custom Docker image that bundles the official [OpenClaw](https://github.com/openclaw/openclaw) agent with the IT0 Bridge process, enabling IT0's agent-service to manage and monitor OpenClaw instances.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (single image, two processes via supervisord)
|
||||||
|
├── openclaw gateway (internal port 18789, NOT exposed)
|
||||||
|
└── it0-bridge (external port 3000, exposed to IT0)
|
||||||
|
├── GET /health — liveness probe
|
||||||
|
├── GET /status — detailed status
|
||||||
|
├── POST /task — submit task to OpenClaw
|
||||||
|
├── GET /sessions — list sessions
|
||||||
|
└── GET /metrics — usage metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t it0hub/openclaw-bridge:latest .
|
||||||
|
docker push it0hub/openclaw-bridge:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENCLAW_GATEWAY_TOKEN` | Yes | Internal OpenClaw gateway auth token |
|
||||||
|
| `CLAUDE_API_KEY` | Yes | Anthropic API key (injected by IT0) |
|
||||||
|
| `IT0_INSTANCE_ID` | Yes | UUID from IT0's agent_instances table |
|
||||||
|
| `IT0_AGENT_SERVICE_URL` | Yes | IT0 agent-service URL for heartbeat |
|
||||||
|
|
||||||
|
## Deploy (via iAgent SSH)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name openclaw-{instanceId} \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p {hostPort}:3000 \
|
||||||
|
-v /data/openclaw/{instanceId}:/home/node/.openclaw \
|
||||||
|
-e OPENCLAW_GATEWAY_TOKEN={token} \
|
||||||
|
-e CLAUDE_API_KEY={claudeApiKey} \
|
||||||
|
-e IT0_INSTANCE_ID={instanceId} \
|
||||||
|
-e IT0_AGENT_SERVICE_URL=https://it0api.szaiai.com \
|
||||||
|
it0hub/openclaw-bridge:latest
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "it0-openclaw-bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "IT0 Bridge process for OpenClaw instances — proxies IT0 API to OpenClaw Gateway WS",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"dotenv": "^16.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"ts-node": "^10.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* IT0 Bridge — runs alongside the OpenClaw gateway inside the container.
|
||||||
|
* Exposes a REST API on port 3000 that:
|
||||||
|
* - Forwards task submissions to OpenClaw via WebSocket RPC
|
||||||
|
* - Returns status, session list, and metrics
|
||||||
|
* - Reports heartbeat to IT0 agent-service
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import { OpenClawClient } from './openclaw-client';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10);
|
||||||
|
const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
|
||||||
|
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
|
||||||
|
const INSTANCE_ID = process.env.IT0_INSTANCE_ID ?? 'unknown';
|
||||||
|
const IT0_AGENT_URL = process.env.IT0_AGENT_SERVICE_URL ?? '';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ── OpenClaw client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ocClient = new OpenClawClient(OPENCLAW_GATEWAY, OPENCLAW_TOKEN);
|
||||||
|
let startTime = Date.now();
|
||||||
|
let gatewayReady = false;
|
||||||
|
|
||||||
|
async function connectWithRetry(maxRetries = 20, delayMs = 3000): Promise<void> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
await ocClient.connect();
|
||||||
|
gatewayReady = true;
|
||||||
|
console.log('[bridge] Connected to OpenClaw gateway');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[bridge] Gateway not ready (attempt ${i + 1}/${maxRetries}), retrying...`);
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Could not connect to OpenClaw gateway after retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Health check — used by IT0 monitoring
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: gatewayReady ? 'ok' : 'starting',
|
||||||
|
instanceId: INSTANCE_ID,
|
||||||
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
|
gatewayConnected: ocClient.isConnected(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status — detailed instance info
|
||||||
|
app.get('/status', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
instanceId: INSTANCE_ID,
|
||||||
|
gatewayConnected: ocClient.isConnected(),
|
||||||
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit a task to OpenClaw
|
||||||
|
app.post('/task', async (req, res) => {
|
||||||
|
if (!ocClient.isConnected()) {
|
||||||
|
res.status(503).json({ error: 'Gateway not connected' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await ocClient.rpc('agent.run', {
|
||||||
|
prompt: req.body.prompt,
|
||||||
|
sessionId: req.body.sessionId,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List sessions
|
||||||
|
app.get('/sessions', async (_req, res) => {
|
||||||
|
if (!ocClient.isConnected()) {
|
||||||
|
res.status(503).json({ error: 'Gateway not connected' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await ocClient.rpc('sessions.list');
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Basic metrics (token usage etc.) — OpenClaw stores these in memory
|
||||||
|
app.get('/metrics', async (_req, res) => {
|
||||||
|
if (!ocClient.isConnected()) {
|
||||||
|
res.status(503).json({ error: 'Gateway not connected' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await ocClient.rpc('metrics.get');
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Heartbeat to IT0 agent-service ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function sendHeartbeat(): Promise<void> {
|
||||||
|
if (!IT0_AGENT_URL || !INSTANCE_ID || INSTANCE_ID === 'unknown') return;
|
||||||
|
try {
|
||||||
|
await fetch(`${IT0_AGENT_URL}/api/v1/agent/instances/${INSTANCE_ID}/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
gatewayConnected: ocClient.isConnected(),
|
||||||
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort, don't crash on network error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[bridge] IT0 Bridge listening on port ${PORT}`);
|
||||||
|
startTime = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
connectWithRetry().catch((err) => {
|
||||||
|
console.error('[bridge] Fatal: could not connect to OpenClaw gateway:', err.message);
|
||||||
|
// Keep bridge running for health-check endpoint even if gateway failed
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat every 60 seconds
|
||||||
|
setInterval(sendHeartbeat, 60_000);
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* OpenClaw Gateway WebSocket client.
|
||||||
|
* Connects to the local OpenClaw gateway (ws://127.0.0.1:18789)
|
||||||
|
* and provides a simple RPC interface.
|
||||||
|
*/
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
interface RpcFrame {
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcResponse {
|
||||||
|
id: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenClawClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||||
|
private readonly gatewayUrl: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private msgCounter = 0;
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
constructor(gatewayUrl: string, token: string) {
|
||||||
|
this.gatewayUrl = gatewayUrl;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.ws = new WebSocket(this.gatewayUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${this.token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.once('open', () => {
|
||||||
|
this.connected = true;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.once('error', (err) => {
|
||||||
|
if (!this.connected) reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const frame: RpcResponse = JSON.parse(raw.toString());
|
||||||
|
const pending = this.pending.get(frame.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(frame.id);
|
||||||
|
if (frame.error) {
|
||||||
|
pending.reject(new Error(frame.error.message));
|
||||||
|
} else {
|
||||||
|
pending.resolve(frame.result);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed frames
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', () => {
|
||||||
|
this.connected = false;
|
||||||
|
// Reject all pending RPCs
|
||||||
|
for (const [, p] of this.pending) {
|
||||||
|
p.reject(new Error('OpenClaw gateway disconnected'));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc(method: string, params?: unknown): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
reject(new Error('Not connected to OpenClaw gateway'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = String(++this.msgCounter);
|
||||||
|
const frame: RpcFrame = { id, method, params };
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
this.ws!.send(JSON.stringify(frame));
|
||||||
|
|
||||||
|
// Timeout after 30s
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pending.has(id)) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`RPC timeout: ${method}`));
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/stdout
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
; OpenClaw gateway process
|
||||||
|
[program:openclaw]
|
||||||
|
command=node /app/openclaw/dist/openclaw.mjs
|
||||||
|
directory=/app/openclaw
|
||||||
|
user=node
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=5
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
environment=HOME="/home/node",NODE_ENV="production",OPENCLAW_GATEWAY_TOKEN="%(ENV_OPENCLAW_GATEWAY_TOKEN)s",CLAUDE_API_KEY="%(ENV_CLAUDE_API_KEY)s"
|
||||||
|
|
||||||
|
; IT0 Bridge process
|
||||||
|
[program:it0-bridge]
|
||||||
|
command=node /app/bridge/dist/index.js
|
||||||
|
directory=/app/bridge
|
||||||
|
user=node
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=10
|
||||||
|
startsecs=5
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
environment=HOME="/home/node",BRIDGE_PORT="3000",OPENCLAW_GATEWAY_URL="ws://127.0.0.1:18789",OPENCLAW_GATEWAY_TOKEN="%(ENV_OPENCLAW_GATEWAY_TOKEN)s",IT0_INSTANCE_ID="%(ENV_IT0_INSTANCE_ID)s",IT0_AGENT_SERVICE_URL="%(ENV_IT0_AGENT_SERVICE_URL)s"
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
"@it0/common": "workspace:*",
|
"@it0/common": "workspace:*",
|
||||||
"@it0/database": "workspace:*",
|
"@it0/database": "workspace:*",
|
||||||
"@it0/events": "workspace:*",
|
"@it0/events": "workspace:*",
|
||||||
"@it0/proto": "workspace:*"
|
"@it0/proto": "workspace:*",
|
||||||
|
"ssh2": "^1.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
|
"@types/ssh2": "^1.11.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ import { ConversationContextService } from './domain/services/conversation-conte
|
||||||
import { VoiceSessionManager } from './domain/services/voice-session-manager.service';
|
import { VoiceSessionManager } from './domain/services/voice-session-manager.service';
|
||||||
import { EventPublisherService } from './infrastructure/messaging/event-publisher.service';
|
import { EventPublisherService } from './infrastructure/messaging/event-publisher.service';
|
||||||
import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
|
import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
|
||||||
|
import { AgentInstance } from './domain/entities/agent-instance.entity';
|
||||||
|
import { AgentInstanceRepository } from './infrastructure/repositories/agent-instance.repository';
|
||||||
|
import { AgentInstanceDeployService } from './infrastructure/services/agent-instance-deploy.service';
|
||||||
|
import { AgentInstanceController } from './interfaces/rest/controllers/agent-instance.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -56,13 +60,14 @@ import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
AgentSession, AgentTask, CommandRecord, StandingOrderRef,
|
AgentSession, AgentTask, CommandRecord, StandingOrderRef,
|
||||||
TenantAgentConfig, AgentConfig, HookScript, VoiceConfig,
|
TenantAgentConfig, AgentConfig, HookScript, VoiceConfig,
|
||||||
ConversationMessage, UsageRecord,
|
ConversationMessage, UsageRecord, AgentInstance,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AgentController, SessionController, RiskRulesController,
|
AgentController, SessionController, RiskRulesController,
|
||||||
TenantAgentConfigController, AgentConfigController, VoiceConfigController,
|
TenantAgentConfigController, AgentConfigController, VoiceConfigController,
|
||||||
VoiceSessionController, SkillsController, HooksController,
|
VoiceSessionController, SkillsController, HooksController,
|
||||||
|
AgentInstanceController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AgentStreamGateway,
|
AgentStreamGateway,
|
||||||
|
|
@ -92,6 +97,8 @@ import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
|
||||||
HookScriptService,
|
HookScriptService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
OpenAISttService,
|
OpenAISttService,
|
||||||
|
AgentInstanceRepository,
|
||||||
|
AgentInstanceDeployService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('agent_instances')
|
||||||
|
export class AgentInstance {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'user_id' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, name: 'agent_type', default: 'openclaw' })
|
||||||
|
agentType!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'pool_server_id', nullable: true })
|
||||||
|
poolServerId?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, name: 'server_host' })
|
||||||
|
serverHost!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', name: 'ssh_port', default: 22 })
|
||||||
|
sshPort!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, name: 'ssh_user' })
|
||||||
|
sshUser!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 150, name: 'container_name', unique: true })
|
||||||
|
containerName!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', name: 'host_port' })
|
||||||
|
hostPort!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'deploying' })
|
||||||
|
status!: 'deploying' | 'running' | 'stopped' | 'error' | 'removed';
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'error_message', nullable: true })
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'openclaw_token', nullable: true })
|
||||||
|
openclawToken?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'openclaw_token_iv', nullable: true })
|
||||||
|
openclawTokenIv?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
config!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { TenantAwareRepository } from '@it0/database';
|
||||||
|
import { AgentInstance } from '../../domain/entities/agent-instance.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentInstanceRepository extends TenantAwareRepository<AgentInstance> {
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
super(dataSource, AgentInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(): Promise<AgentInstance[]> {
|
||||||
|
return this.withRepository((repo) =>
|
||||||
|
repo.find({ order: { createdAt: 'DESC' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<AgentInstance | null> {
|
||||||
|
return this.withRepository((repo) =>
|
||||||
|
repo.findOne({ where: { id } as any }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByUserId(userId: string): Promise<AgentInstance[]> {
|
||||||
|
return this.withRepository((repo) =>
|
||||||
|
repo.find({ where: { userId } as any, order: { createdAt: 'DESC' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByPoolServer(poolServerId: string): Promise<AgentInstance[]> {
|
||||||
|
return this.withRepository((repo) =>
|
||||||
|
repo.find({ where: { poolServerId, status: 'running' } as any }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(instance: AgentInstance): Promise<AgentInstance> {
|
||||||
|
return this.withRepository((repo) => repo.save(instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(instance: AgentInstance): Promise<void> {
|
||||||
|
await this.withRepository((repo) => repo.remove(instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all host ports in use on a specific server (across all tenants via public query)
|
||||||
|
async getUsedPortsOnServer(dataSource: DataSource, serverHost: string): Promise<number[]> {
|
||||||
|
const rows = await dataSource.query(
|
||||||
|
`SELECT host_port FROM agent_instances WHERE server_host = $1 AND status != 'removed'`,
|
||||||
|
[serverHost],
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => r.host_port as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { AgentInstance } from '../../domain/entities/agent-instance.entity';
|
||||||
|
import { AgentInstanceRepository } from '../repositories/agent-instance.repository';
|
||||||
|
|
||||||
|
const OPENCLAW_IMAGE = 'it0hub/openclaw-bridge:latest';
|
||||||
|
const PORT_RANGE_START = 20000;
|
||||||
|
const PORT_RANGE_END = 29999;
|
||||||
|
const INTERNAL_API_KEY_HEADER = 'x-internal-api-key';
|
||||||
|
|
||||||
|
interface PoolServerCreds {
|
||||||
|
id: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
sshUser: string;
|
||||||
|
sshKey: string; // decrypted private key
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeployOptions {
|
||||||
|
instanceId: string;
|
||||||
|
containerName: string;
|
||||||
|
hostPort: number;
|
||||||
|
userId: string;
|
||||||
|
claudeApiKey: string;
|
||||||
|
openclawToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentInstanceDeployService {
|
||||||
|
private readonly logger = new Logger(AgentInstanceDeployService.name);
|
||||||
|
private readonly inventoryUrl: string;
|
||||||
|
private readonly internalApiKey: string;
|
||||||
|
private readonly encKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly instanceRepo: AgentInstanceRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
this.inventoryUrl = this.configService.get<string>('INVENTORY_SERVICE_URL', 'http://inventory-service:3004');
|
||||||
|
this.internalApiKey = this.configService.get<string>('INTERNAL_API_KEY', '');
|
||||||
|
this.encKey = this.configService.get<string>('VAULT_MASTER_KEY', 'dev-master-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async deployFromPool(instance: AgentInstance, claudeApiKey: string): Promise<void> {
|
||||||
|
const server = await this.pickPoolServer();
|
||||||
|
const creds = await this.fetchSshCreds(server.id);
|
||||||
|
const hostPort = await this.allocatePort(creds.host);
|
||||||
|
|
||||||
|
instance.serverHost = creds.host;
|
||||||
|
instance.sshPort = creds.sshPort;
|
||||||
|
instance.sshUser = creds.sshUser;
|
||||||
|
instance.hostPort = hostPort;
|
||||||
|
instance.poolServerId = server.id;
|
||||||
|
|
||||||
|
await this.runDeploy(instance, creds, claudeApiKey);
|
||||||
|
await this.notifyPoolIncrement(server.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployToUserServer(
|
||||||
|
instance: AgentInstance,
|
||||||
|
sshKey: string,
|
||||||
|
claudeApiKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const creds: PoolServerCreds = {
|
||||||
|
id: '',
|
||||||
|
host: instance.serverHost,
|
||||||
|
sshPort: instance.sshPort,
|
||||||
|
sshUser: instance.sshUser,
|
||||||
|
sshKey,
|
||||||
|
};
|
||||||
|
const hostPort = await this.allocatePort(creds.host);
|
||||||
|
instance.hostPort = hostPort;
|
||||||
|
|
||||||
|
await this.runDeploy(instance, creds, claudeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopInstance(instance: AgentInstance): Promise<void> {
|
||||||
|
const sshKey = instance.poolServerId
|
||||||
|
? (await this.fetchSshCreds(instance.poolServerId)).sshKey
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!sshKey) {
|
||||||
|
throw new InternalServerErrorException('Cannot stop user-server instance without SSH key');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sshExec(
|
||||||
|
{ host: instance.serverHost, port: instance.sshPort, username: instance.sshUser, privateKey: sshKey },
|
||||||
|
`docker stop ${instance.containerName} || true`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInstance(instance: AgentInstance): Promise<void> {
|
||||||
|
if (instance.poolServerId) {
|
||||||
|
const creds = await this.fetchSshCreds(instance.poolServerId);
|
||||||
|
await this.sshExec(
|
||||||
|
{ host: instance.serverHost, port: instance.sshPort, username: instance.sshUser, privateKey: creds.sshKey },
|
||||||
|
`docker rm -f ${instance.containerName} || true`,
|
||||||
|
);
|
||||||
|
await this.notifyPoolDecrement(instance.poolServerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
encryptToken(plaintext: string): { encrypted: string; iv: string } {
|
||||||
|
const ivBuf = crypto.randomBytes(16);
|
||||||
|
const keyBuf = crypto.scryptSync(this.encKey, 'salt', 32);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuf, ivBuf);
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
return { encrypted: enc.toString('base64'), iv: ivBuf.toString('base64') };
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptToken(encrypted: string, iv: string): string {
|
||||||
|
const keyBuf = crypto.scryptSync(this.encKey, 'salt', 32);
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'aes-256-cbc',
|
||||||
|
keyBuf,
|
||||||
|
Buffer.from(iv, 'base64'),
|
||||||
|
);
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encrypted, 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async pickPoolServer(): Promise<{ id: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this.inventoryUrl}/api/v1/inventory/pool-servers?available=true`,
|
||||||
|
{ headers: { [INTERNAL_API_KEY_HEADER]: this.internalApiKey } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new InternalServerErrorException('Failed to query pool servers');
|
||||||
|
const servers: any[] = await res.json();
|
||||||
|
if (!servers.length) throw new InternalServerErrorException('No available pool servers');
|
||||||
|
return servers[0]; // Already sorted by current_instances ASC
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSshCreds(poolServerId: string): Promise<PoolServerCreds> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${this.inventoryUrl}/api/v1/inventory/pool-servers/${poolServerId}/deploy-creds`,
|
||||||
|
{ headers: { [INTERNAL_API_KEY_HEADER]: this.internalApiKey } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new InternalServerErrorException('Failed to fetch SSH credentials');
|
||||||
|
return res.json() as Promise<PoolServerCreds>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async allocatePort(serverHost: string): Promise<number> {
|
||||||
|
const used = await this.instanceRepo.getUsedPortsOnServer(this.dataSource, serverHost);
|
||||||
|
for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) {
|
||||||
|
if (!used.includes(p)) return p;
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException('No available ports on server');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runDeploy(
|
||||||
|
instance: AgentInstance,
|
||||||
|
creds: PoolServerCreds,
|
||||||
|
claudeApiKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const { encrypted, iv } = this.encryptToken(token);
|
||||||
|
instance.openclawToken = encrypted;
|
||||||
|
instance.openclawTokenIv = iv;
|
||||||
|
|
||||||
|
const cmd = [
|
||||||
|
'docker run -d',
|
||||||
|
`--name ${instance.containerName}`,
|
||||||
|
`--restart unless-stopped`,
|
||||||
|
`-p ${instance.hostPort}:3000`,
|
||||||
|
`-v /data/openclaw/${instance.id}:/home/node/.openclaw`,
|
||||||
|
`-e OPENCLAW_GATEWAY_TOKEN=${token}`,
|
||||||
|
`-e IT0_INSTANCE_ID=${instance.id}`,
|
||||||
|
`-e CLAUDE_API_KEY=${claudeApiKey}`,
|
||||||
|
OPENCLAW_IMAGE,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
this.logger.log(`Deploying ${instance.containerName} on ${creds.host}:${instance.hostPort}`);
|
||||||
|
|
||||||
|
await this.sshExec(
|
||||||
|
{ host: creds.host, port: creds.sshPort, username: creds.sshUser, privateKey: creds.sshKey },
|
||||||
|
cmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
instance.status = 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyPoolIncrement(poolServerId: string): Promise<void> {
|
||||||
|
await fetch(
|
||||||
|
`${this.inventoryUrl}/api/v1/inventory/pool-servers/${poolServerId}/increment`,
|
||||||
|
{ method: 'POST', headers: { [INTERNAL_API_KEY_HEADER]: this.internalApiKey } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyPoolDecrement(poolServerId: string): Promise<void> {
|
||||||
|
await fetch(
|
||||||
|
`${this.inventoryUrl}/api/v1/inventory/pool-servers/${poolServerId}/decrement`,
|
||||||
|
{ method: 'POST', headers: { [INTERNAL_API_KEY_HEADER]: this.internalApiKey } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sshExec(config: ConnectConfig, command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const conn = new SshClient();
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
conn
|
||||||
|
.on('ready', () => {
|
||||||
|
conn.exec(command, (err, stream) => {
|
||||||
|
if (err) { conn.end(); reject(err); return; }
|
||||||
|
|
||||||
|
stream
|
||||||
|
.on('close', (code: number) => {
|
||||||
|
conn.end();
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`SSH command exited with code ${code}: ${output}`));
|
||||||
|
} else {
|
||||||
|
resolve(output);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('data', (data: Buffer) => { output += data.toString(); })
|
||||||
|
.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
.connect(config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository';
|
||||||
|
import { AgentInstanceDeployService } from '../../../infrastructure/services/agent-instance-deploy.service';
|
||||||
|
import { AgentInstance } from '../../../domain/entities/agent-instance.entity';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Controller('api/v1/agent/instances')
|
||||||
|
export class AgentInstanceController {
|
||||||
|
private readonly claudeApiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly instanceRepo: AgentInstanceRepository,
|
||||||
|
private readonly deployService: AgentInstanceDeployService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.claudeApiKey = this.configService.get<string>('ANTHROPIC_API_KEY', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return this.instanceRepo.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getOne(@Param('id') id: string) {
|
||||||
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${id} not found`);
|
||||||
|
return this.sanitize(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() body: {
|
||||||
|
name: string;
|
||||||
|
agentType?: string;
|
||||||
|
userId: string;
|
||||||
|
// Pool-server deployment (no server creds needed)
|
||||||
|
usePool?: boolean;
|
||||||
|
// User-owned server deployment
|
||||||
|
serverHost?: string;
|
||||||
|
sshPort?: number;
|
||||||
|
sshUser?: string;
|
||||||
|
sshKey?: string; // plaintext private key, only used if usePool=false
|
||||||
|
}) {
|
||||||
|
if (!body.name || !body.userId) {
|
||||||
|
throw new BadRequestException('name and userId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new AgentInstance();
|
||||||
|
instance.id = crypto.randomUUID();
|
||||||
|
instance.userId = body.userId;
|
||||||
|
instance.name = body.name;
|
||||||
|
instance.agentType = body.agentType ?? 'openclaw';
|
||||||
|
instance.containerName = `openclaw-${instance.id.slice(0, 8)}`;
|
||||||
|
instance.status = 'deploying';
|
||||||
|
instance.config = {};
|
||||||
|
instance.hostPort = 0; // Will be set by deploy service
|
||||||
|
|
||||||
|
if (body.usePool !== false) {
|
||||||
|
await this.instanceRepo.save(instance);
|
||||||
|
this.deployService.deployFromPool(instance, this.claudeApiKey)
|
||||||
|
.then(() => this.instanceRepo.save(instance))
|
||||||
|
.catch(async (err) => {
|
||||||
|
instance.status = 'error';
|
||||||
|
instance.errorMessage = err.message;
|
||||||
|
await this.instanceRepo.save(instance);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!body.serverHost || !body.sshUser || !body.sshKey) {
|
||||||
|
throw new BadRequestException('serverHost, sshUser, sshKey required for user-owned server');
|
||||||
|
}
|
||||||
|
instance.serverHost = body.serverHost;
|
||||||
|
instance.sshPort = body.sshPort ?? 22;
|
||||||
|
instance.sshUser = body.sshUser;
|
||||||
|
await this.instanceRepo.save(instance);
|
||||||
|
this.deployService.deployToUserServer(instance, body.sshKey, this.claudeApiKey)
|
||||||
|
.then(() => this.instanceRepo.save(instance))
|
||||||
|
.catch(async (err) => {
|
||||||
|
instance.status = 'error';
|
||||||
|
instance.errorMessage = err.message;
|
||||||
|
await this.instanceRepo.save(instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sanitize(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/stop')
|
||||||
|
async stop(@Param('id') id: string) {
|
||||||
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${id} not found`);
|
||||||
|
await this.deployService.stopInstance(inst);
|
||||||
|
inst.status = 'stopped';
|
||||||
|
return this.sanitize(await this.instanceRepo.save(inst));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/name')
|
||||||
|
async rename(@Param('id') id: string, @Body() body: { name: string }) {
|
||||||
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${id} not found`);
|
||||||
|
inst.name = body.name;
|
||||||
|
return this.sanitize(await this.instanceRepo.save(inst));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${id} not found`);
|
||||||
|
await this.deployService.removeInstance(inst);
|
||||||
|
inst.status = 'removed';
|
||||||
|
await this.instanceRepo.save(inst);
|
||||||
|
return { message: 'Instance removed', id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitize(inst: AgentInstance) {
|
||||||
|
const { openclawToken, openclawTokenIv, ...safe } = inst;
|
||||||
|
return { ...safe, hasToken: !!openclawToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'pool_servers', schema: 'public' })
|
||||||
|
export class PoolServer {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
host!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 22 })
|
||||||
|
sshPort!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
sshUser!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'ssh_key_encrypted' })
|
||||||
|
sshKeyEncrypted!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'ssh_key_iv' })
|
||||||
|
sshKeyIv!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 10, name: 'max_instances' })
|
||||||
|
maxInstances!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'current_instances' })
|
||||||
|
currentInstances!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status!: 'active' | 'maintenance' | 'offline';
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { PoolServer } from '../../domain/entities/pool-server.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PoolServerRepository {
|
||||||
|
private readonly repo: Repository<PoolServer>;
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {
|
||||||
|
this.repo = this.dataSource.getRepository(PoolServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(): Promise<PoolServer[]> {
|
||||||
|
return this.repo.find({ order: { createdAt: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findAvailable(): Promise<PoolServer[]> {
|
||||||
|
return this.repo
|
||||||
|
.createQueryBuilder('ps')
|
||||||
|
.where('ps.status = :status', { status: 'active' })
|
||||||
|
.andWhere('ps.current_instances < ps.max_instances')
|
||||||
|
.orderBy('ps.current_instances', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<PoolServer | null> {
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(server: PoolServer): Promise<PoolServer> {
|
||||||
|
return this.repo.save(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(server: PoolServer): Promise<void> {
|
||||||
|
await this.repo.remove(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementInstances(id: string): Promise<void> {
|
||||||
|
await this.repo.increment({ id }, 'currentInstances', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrementInstances(id: string): Promise<void> {
|
||||||
|
await this.repo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(PoolServer)
|
||||||
|
.set({ currentInstances: () => 'GREATEST(current_instances - 1, 0)' })
|
||||||
|
.where('id = :id', { id })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Headers,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PoolServerRepository } from '../../../infrastructure/repositories/pool-server.repository';
|
||||||
|
import { CredentialVaultService } from '../../../infrastructure/crypto/credential-vault.service';
|
||||||
|
import { PoolServer } from '../../../domain/entities/pool-server.entity';
|
||||||
|
|
||||||
|
@Controller('api/v1/inventory/pool-servers')
|
||||||
|
export class PoolServerController {
|
||||||
|
private readonly internalApiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly poolServerRepository: PoolServerRepository,
|
||||||
|
private readonly vault: CredentialVaultService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.internalApiKey = this.configService.get<string>('INTERNAL_API_KEY', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(server: PoolServer) {
|
||||||
|
// Never return raw encrypted key
|
||||||
|
const { sshKeyEncrypted, sshKeyIv, ...safe } = server;
|
||||||
|
return {
|
||||||
|
...safe,
|
||||||
|
hasKey: !!sshKeyEncrypted,
|
||||||
|
available: server.status === 'active' && server.currentInstances < server.maxInstances,
|
||||||
|
freeSlots: Math.max(0, server.maxInstances - server.currentInstances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Query('available') available?: string) {
|
||||||
|
const servers = available === 'true'
|
||||||
|
? await this.poolServerRepository.findAvailable()
|
||||||
|
: await this.poolServerRepository.findAll();
|
||||||
|
return servers.map((s) => this.serialize(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getOne(@Param('id') id: string) {
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
return this.serialize(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() body: {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
sshPort?: number;
|
||||||
|
sshUser: string;
|
||||||
|
sshKey: string; // plaintext private key, encrypted immediately
|
||||||
|
maxInstances?: number;
|
||||||
|
region?: string;
|
||||||
|
notes?: string;
|
||||||
|
}) {
|
||||||
|
if (!body.name || !body.host || !body.sshUser || !body.sshKey) {
|
||||||
|
throw new BadRequestException('name, host, sshUser, sshKey are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { encrypted, iv } = this.vault.encrypt(body.sshKey);
|
||||||
|
|
||||||
|
const server = new PoolServer();
|
||||||
|
server.id = crypto.randomUUID();
|
||||||
|
server.name = body.name;
|
||||||
|
server.host = body.host;
|
||||||
|
server.sshPort = body.sshPort ?? 22;
|
||||||
|
server.sshUser = body.sshUser;
|
||||||
|
server.sshKeyEncrypted = encrypted.toString('base64');
|
||||||
|
server.sshKeyIv = iv.toString('base64');
|
||||||
|
server.maxInstances = body.maxInstances ?? 10;
|
||||||
|
server.currentInstances = 0;
|
||||||
|
server.status = 'active';
|
||||||
|
server.region = body.region;
|
||||||
|
server.notes = body.notes;
|
||||||
|
|
||||||
|
return this.serialize(await this.poolServerRepository.save(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(@Param('id') id: string, @Body() body: {
|
||||||
|
name?: string;
|
||||||
|
maxInstances?: number;
|
||||||
|
status?: 'active' | 'maintenance' | 'offline';
|
||||||
|
region?: string;
|
||||||
|
notes?: string;
|
||||||
|
sshKey?: string; // optional re-key
|
||||||
|
}) {
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
|
||||||
|
if (body.name !== undefined) server.name = body.name;
|
||||||
|
if (body.maxInstances !== undefined) server.maxInstances = body.maxInstances;
|
||||||
|
if (body.status !== undefined) server.status = body.status;
|
||||||
|
if (body.region !== undefined) server.region = body.region;
|
||||||
|
if (body.notes !== undefined) server.notes = body.notes;
|
||||||
|
if (body.sshKey) {
|
||||||
|
const { encrypted, iv } = this.vault.encrypt(body.sshKey);
|
||||||
|
server.sshKeyEncrypted = encrypted.toString('base64');
|
||||||
|
server.sshKeyIv = iv.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serialize(await this.poolServerRepository.save(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
if (server.currentInstances > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete server with ${server.currentInstances} active instance(s). Stop all instances first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.poolServerRepository.remove(server);
|
||||||
|
return { message: 'Pool server deleted', id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal endpoint: called by agent-service to get decrypted SSH credentials for deployment
|
||||||
|
@Get(':id/deploy-creds')
|
||||||
|
async deployCreds(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('x-internal-api-key') key: string,
|
||||||
|
) {
|
||||||
|
if (!this.internalApiKey || key !== this.internalApiKey) {
|
||||||
|
throw new UnauthorizedException('Invalid internal API key');
|
||||||
|
}
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
|
||||||
|
const sshKey = this.vault.decrypt(
|
||||||
|
Buffer.from(server.sshKeyEncrypted, 'base64'),
|
||||||
|
Buffer.from(server.sshKeyIv, 'base64'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
host: server.host,
|
||||||
|
sshPort: server.sshPort,
|
||||||
|
sshUser: server.sshUser,
|
||||||
|
sshKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal endpoint: called by agent-service after deploying / removing an OpenClaw instance
|
||||||
|
@Post(':id/increment')
|
||||||
|
async increment(@Param('id') id: string) {
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
await this.poolServerRepository.incrementInstances(id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/decrement')
|
||||||
|
async decrement(@Param('id') id: string) {
|
||||||
|
const server = await this.poolServerRepository.findById(id);
|
||||||
|
if (!server) throw new NotFoundException(`Pool server ${id} not found`);
|
||||||
|
await this.poolServerRepository.decrementInstances(id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,18 +9,21 @@ import { ServerRepository } from './infrastructure/repositories/server.repositor
|
||||||
import { ClusterRepository } from './infrastructure/repositories/cluster.repository';
|
import { ClusterRepository } from './infrastructure/repositories/cluster.repository';
|
||||||
import { CredentialController } from './interfaces/rest/controllers/credential.controller';
|
import { CredentialController } from './interfaces/rest/controllers/credential.controller';
|
||||||
import { CredentialRepository } from './infrastructure/repositories/credential.repository';
|
import { CredentialRepository } from './infrastructure/repositories/credential.repository';
|
||||||
|
import { PoolServerController } from './interfaces/rest/controllers/pool-server.controller';
|
||||||
|
import { PoolServerRepository } from './infrastructure/repositories/pool-server.repository';
|
||||||
import { Server } from './domain/entities/server.entity';
|
import { Server } from './domain/entities/server.entity';
|
||||||
import { Cluster } from './domain/entities/cluster.entity';
|
import { Cluster } from './domain/entities/cluster.entity';
|
||||||
import { Credential } from './domain/entities/credential.entity';
|
import { Credential } from './domain/entities/credential.entity';
|
||||||
import { SshConfig } from './domain/entities/ssh-config.entity';
|
import { SshConfig } from './domain/entities/ssh-config.entity';
|
||||||
|
import { PoolServer } from './domain/entities/pool-server.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
DatabaseModule.forRoot(),
|
DatabaseModule.forRoot(),
|
||||||
TypeOrmModule.forFeature([Server, Cluster, Credential, SshConfig]),
|
TypeOrmModule.forFeature([Server, Cluster, Credential, SshConfig, PoolServer]),
|
||||||
],
|
],
|
||||||
controllers: [ServerController, ClusterController, CredentialController],
|
controllers: [ServerController, ClusterController, CredentialController, PoolServerController],
|
||||||
providers: [CredentialVaultService, ServerRepository, ClusterRepository, CredentialRepository],
|
providers: [CredentialVaultService, ServerRepository, ClusterRepository, CredentialRepository, PoolServerRepository],
|
||||||
})
|
})
|
||||||
export class InventoryModule {}
|
export class InventoryModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- IT0 Pool Servers (public schema)
|
||||||
|
-- Platform-level server pool for OpenClaw instance deployment
|
||||||
|
-- Managed by platform_admin only
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.pool_servers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL, -- Display name, e.g. "训练服务器"
|
||||||
|
host VARCHAR(255) NOT NULL, -- IP or hostname
|
||||||
|
ssh_port INT NOT NULL DEFAULT 22,
|
||||||
|
ssh_user VARCHAR(100) NOT NULL,
|
||||||
|
ssh_key_encrypted TEXT NOT NULL, -- AES-256 encrypted private key (base64)
|
||||||
|
ssh_key_iv TEXT NOT NULL, -- AES-256 IV (base64)
|
||||||
|
max_instances INT NOT NULL DEFAULT 10, -- Admin-set capacity limit
|
||||||
|
current_instances INT NOT NULL DEFAULT 0, -- Live counter, updated on deploy/remove
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | maintenance | offline
|
||||||
|
region VARCHAR(100), -- Optional label e.g. "香港", "深圳"
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pool_servers_status ON public.pool_servers(status);
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- IT0 Agent Instances (tenant schema)
|
||||||
|
-- Tracks deployed OpenClaw containers per tenant/user
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL, -- User-given name e.g. "我的OpenClaw"
|
||||||
|
agent_type VARCHAR(50) NOT NULL DEFAULT 'openclaw', -- openclaw | future types
|
||||||
|
pool_server_id UUID, -- NULL if user-owned server
|
||||||
|
server_host VARCHAR(255) NOT NULL, -- Actual host used
|
||||||
|
ssh_port INT NOT NULL DEFAULT 22,
|
||||||
|
ssh_user VARCHAR(100) NOT NULL,
|
||||||
|
container_name VARCHAR(150) NOT NULL UNIQUE, -- docker container name
|
||||||
|
host_port INT NOT NULL, -- Mapped port on host (e.g. 20001)
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'deploying', -- deploying|running|stopped|error|removed
|
||||||
|
error_message TEXT,
|
||||||
|
openclaw_token TEXT, -- OPENCLAW_GATEWAY_TOKEN (encrypted, base64)
|
||||||
|
openclaw_token_iv TEXT, -- AES IV (base64)
|
||||||
|
config JSONB NOT NULL DEFAULT '{}', -- OpenClaw channel configs etc.
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_instances_user ON agent_instances(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_instances_status ON agent_instances(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_instances_pool_server ON agent_instances(pool_server_id);
|
||||||
Loading…
Reference in New Issue