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:
hailin 2026-03-07 11:11:21 -08:00
parent 29a85dfe92
commit 7d5840c245
26 changed files with 2352 additions and 298 deletions

View File

@ -0,0 +1,221 @@
# OpenClaw 集成方案设计
> 状态: 讨论中,尚未开始实现
> 日期: 2026-03-07
---
## 一、OpenClaw 是什么
- **GitHub**: https://github.com/openclaw/openclaw
- **定性**: 开源自主 AI Agent2026年初爆火前身为 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, -- JSONBOpenClaw 配置
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 自身支持多 channelTelegram、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个
- [ ] 实例健康监控 + 自动重启(心跳检测)

View File

@ -140,6 +140,9 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- 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:
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
@ -206,6 +209,7 @@ services:
- DB_DATABASE=${POSTGRES_DB:-it0}
- REDIS_URL=redis://redis:6379
- VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-changeme-internal-key}
- INVENTORY_SERVICE_PORT=3004
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))\""]

View File

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

View File

@ -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-----&#10;..."
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>
);
}

View File

@ -24,6 +24,8 @@ import {
PanelLeft,
CreditCard,
Smartphone,
Database,
Boxes,
} from 'lucide-react';
/* ---------- Sidebar context for collapse state ---------- */
@ -108,6 +110,8 @@ export function Sidebar() {
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 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: 'serverPool', label: '服务器池', href: '/server-pool', icon: <Database className={iconClass} /> },
{ key: 'openclawInstances', label: 'OpenClaw 实例', href: '/openclaw-instances', icon: <Boxes className={iconClass} /> },
{
key: 'billing',
label: t('billing'),

View File

@ -22,6 +22,7 @@ class ApiEndpoints {
static const String sessions = '$agent/sessions';
static const String engines = '$agent/engines';
static const String agentConfigs = '$agent/configs';
static const String agentInstances = '$agent/instances';
// Ops
static const String opsTasks = '$ops/tasks';

View File

@ -4,22 +4,63 @@ import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
// ---------------------------------------------------------------------------
// Provider: fetches agent configs (user-created + official)
// Model
// ---------------------------------------------------------------------------
final myAgentsProvider =
FutureProvider<List<Map<String, dynamic>>>((ref) async {
class AgentInstance {
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);
try {
final response = await dio.get(ApiEndpoints.agentConfigs);
final data = response.data;
if (data is List) return data.cast<Map<String, dynamic>>();
if (data is Map && data.containsKey('items')) {
return (data['items'] as List).cast<Map<String, dynamic>>();
final res = await dio.get(ApiEndpoints.agentInstances);
final data = res.data;
if (data is List) {
return data
.cast<Map<String, dynamic>>()
.map(AgentInstance.fromJson)
.where((i) => i.status != 'removed')
.toList();
}
return [];
} 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 {
@ -36,7 +102,7 @@ class MyAgentsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final agentsAsync = ref.watch(myAgentsProvider);
final instancesAsync = ref.watch(myInstancesProvider);
return Scaffold(
backgroundColor: AppColors.background,
@ -46,19 +112,18 @@ class MyAgentsPage extends ConsumerWidget {
actions: [
IconButton(
icon: const Icon(Icons.refresh_outlined),
onPressed: () => ref.invalidate(myAgentsProvider),
onPressed: () => ref.invalidate(myInstancesProvider),
),
],
),
body: agentsAsync.when(
data: (agents) => agents.isEmpty
body: instancesAsync.when(
data: (instances) => instances.isEmpty
? _buildEmptyGuide(context)
: _buildAgentList(context, ref, agents),
loading: () =>
const Center(child: CircularProgressIndicator()),
: _buildInstanceList(context, ref, instances),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
error: e,
onRetry: () => ref.invalidate(myAgentsProvider),
onRetry: () => ref.invalidate(myInstancesProvider),
),
),
);
@ -70,7 +135,6 @@ class MyAgentsPage extends ConsumerWidget {
child: Column(
children: [
const SizedBox(height: 40),
// Main illustration
Container(
width: 120,
height: 120,
@ -78,58 +142,25 @@ class MyAgentsPage extends ConsumerWidget {
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.smart_toy_outlined,
size: 60,
color: AppColors.primary,
),
child: const Icon(Icons.smart_toy_outlined, size: 60, color: AppColors.primary),
),
const SizedBox(height: 24),
const Text(
'创建你的专属智能体',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
),
const SizedBox(height: 10),
const Text(
'通过与 iAgent 对话,你可以创建各种智能体:\nOpenClaw 编程助手、运维机器人、数据分析师...',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.6,
),
style: TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.6),
textAlign: TextAlign.center,
),
const SizedBox(height: 36),
// Step cards
_StepCard(
step: '1',
title: '点击下方机器人',
desc: '打开与 iAgent 的对话窗口',
icon: Icons.smart_toy_outlined,
color: AppColors.primary,
),
_StepCard(step: '1', title: '点击下方机器人', desc: '打开与 iAgent 的对话窗口', icon: Icons.smart_toy_outlined, color: AppColors.primary),
const SizedBox(height: 12),
_StepCard(
step: '2',
title: '描述你想要的智能体',
desc: '例如:"帮我创建一个监控 GitHub Actions 的 OpenClaw 智能体"',
icon: Icons.record_voice_over_outlined,
color: const Color(0xFF0EA5E9),
),
_StepCard(step: '2', title: '描述你想要的智能体', desc: '例如:"帮我创建一个 OpenClaw 编程助手"', icon: Icons.record_voice_over_outlined, color: const Color(0xFF0EA5E9)),
const SizedBox(height: 12),
_StepCard(
step: '3',
title: 'iAgent 自动完成配置',
desc: '智能体会出现在这里,随时可以与它对话',
icon: Icons.check_circle_outline,
color: AppColors.success,
),
_StepCard(step: '3', title: 'iAgent 自动部署', desc: '部署完成后出现在这里,通过 Telegram/WhatsApp 等渠道与它对话', icon: Icons.check_circle_outline, color: AppColors.success),
const SizedBox(height: 36),
const _TemplatesSection(),
const SizedBox(height: 100),
@ -138,18 +169,198 @@ class MyAgentsPage extends ConsumerWidget {
);
}
Widget _buildAgentList(
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> agents) {
Widget _buildInstanceList(BuildContext context, WidgetRef ref, List<AgentInstance> instances) {
final running = instances.where((i) => i.status == 'running').length;
return RefreshIndicator(
onRefresh: () async => ref.invalidate(myAgentsProvider),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: agents.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final agent = agents[index];
return _AgentListCard(agent: agent);
onRefresh: () async => ref.invalidate(myInstancesProvider),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
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 Color color;
const _StepCard({
required this.step,
required this.title,
required this.desc,
required this.icon,
required this.color,
});
const _StepCard({required this.step, required this.title, required this.desc, required this.icon, required this.color});
@override
Widget build(BuildContext context) {
@ -185,47 +390,20 @@ class _StepCard extends StatelessWidget {
),
child: Row(
children: [
// Step number circle
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Center(
child: Text(
step,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
decoration: BoxDecoration(color: color.withOpacity(0.15), shape: BoxShape.circle),
child: Center(child: Text(step, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14))),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 3),
Text(
desc,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
height: 1.4,
),
),
Text(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 {
const _TemplatesSection();
static const _templates = [
_Template(
name: 'OpenClaw 编程助手',
desc: '代码审查、自动化测试、CI/CD 管理',
icon: Icons.code_outlined,
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),
),
_Template(name: 'OpenClaw 编程助手', desc: '代码审查、自动化测试', icon: Icons.code_outlined, 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
@ -275,14 +433,7 @@ class _TemplatesSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'热门模板(告诉 iAgent 你想要哪种)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const Text('热门模板(告诉 iAgent 你想要哪种)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
const SizedBox(height: 12),
GridView.count(
shrinkWrap: true,
@ -291,9 +442,7 @@ class _TemplatesSection extends StatelessWidget {
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.5,
children: _templates
.map((t) => _TemplateChip(template: t))
.toList(),
children: _templates.map((t) => _TemplateChip(template: t)).toList(),
),
],
);
@ -305,18 +454,11 @@ class _Template {
final String desc;
final IconData icon;
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 {
final _Template template;
const _TemplateChip({required this.template});
@override
@ -326,8 +468,7 @@ class _TemplateChip extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: template.color.withOpacity(0.25)),
border: Border.all(color: template.color.withOpacity(0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -337,26 +478,9 @@ class _TemplateChip extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
template.name,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(template.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textPrimary), maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(
template.desc,
style: const TextStyle(
fontSize: 10,
color: AppColors.textMuted,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(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,
),
],
),
);
}
}

View File

@ -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"]

View File

@ -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
```

View File

@ -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"
}
}

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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"

View File

@ -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"]
}

View File

@ -31,7 +31,8 @@
"@it0/common": "workspace:*",
"@it0/database": "workspace:*",
"@it0/events": "workspace:*",
"@it0/proto": "workspace:*"
"@it0/proto": "workspace:*",
"ssh2": "^1.15.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
@ -41,6 +42,7 @@
"@types/jest": "^29.5.0",
"@types/multer": "^1.4.11",
"@types/ws": "^8.5.0",
"@types/ssh2": "^1.11.0",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",

View File

@ -48,6 +48,10 @@ import { ConversationContextService } from './domain/services/conversation-conte
import { VoiceSessionManager } from './domain/services/voice-session-manager.service';
import { EventPublisherService } from './infrastructure/messaging/event-publisher.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({
imports: [
@ -56,13 +60,14 @@ import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
TypeOrmModule.forFeature([
AgentSession, AgentTask, CommandRecord, StandingOrderRef,
TenantAgentConfig, AgentConfig, HookScript, VoiceConfig,
ConversationMessage, UsageRecord,
ConversationMessage, UsageRecord, AgentInstance,
]),
],
controllers: [
AgentController, SessionController, RiskRulesController,
TenantAgentConfigController, AgentConfigController, VoiceConfigController,
VoiceSessionController, SkillsController, HooksController,
AgentInstanceController,
],
providers: [
AgentStreamGateway,
@ -92,6 +97,8 @@ import { OpenAISttService } from './infrastructure/stt/openai-stt.service';
HookScriptService,
EventPublisherService,
OpenAISttService,
AgentInstanceRepository,
AgentInstanceDeployService,
],
})
export class AgentModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}
}

View File

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

View File

@ -9,18 +9,21 @@ import { ServerRepository } from './infrastructure/repositories/server.repositor
import { ClusterRepository } from './infrastructure/repositories/cluster.repository';
import { CredentialController } from './interfaces/rest/controllers/credential.controller';
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 { Cluster } from './domain/entities/cluster.entity';
import { Credential } from './domain/entities/credential.entity';
import { SshConfig } from './domain/entities/ssh-config.entity';
import { PoolServer } from './domain/entities/pool-server.entity';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule.forRoot(),
TypeOrmModule.forFeature([Server, Cluster, Credential, SshConfig]),
TypeOrmModule.forFeature([Server, Cluster, Credential, SshConfig, PoolServer]),
],
controllers: [ServerController, ClusterController, CredentialController],
providers: [CredentialVaultService, ServerRepository, ClusterRepository, CredentialRepository],
controllers: [ServerController, ClusterController, CredentialController, PoolServerController],
providers: [CredentialVaultService, ServerRepository, ClusterRepository, CredentialRepository, PoolServerRepository],
})
export class InventoryModule {}

View File

@ -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);

View File

@ -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);