This commit is contained in:
王佑琳 2026-03-13 18:38:01 +08:00
commit 08424049da
58 changed files with 11717 additions and 0 deletions

335
API.md Normal file
View File

@ -0,0 +1,335 @@
# API 接口文档
## 基础信息
- 基础 URL: `http://localhost:3000/api`
- 认证方式: JWT Bearer Token
- 数据格式: JSON
## 认证
### 登录
```http
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
```
响应:
```json
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"username": "admin"
}
}
}
```
后续请求需在 Header 中携带:
```
Authorization: Bearer <token>
```
---
## 实例管理
### 获取所有实例列表
```http
GET /api/instances
Authorization: Bearer <token>
```
响应:
```json
{
"code": 200,
"data": [
{
"id": "127.0.0.1:8001",
"serverId": "server-1",
"serverName": "主服务器",
"ip": "127.0.0.1",
"port": 8001,
"enabled": true,
"alive": true,
"busy": false,
"lastHeartbeat": "2024-01-01T00:00:00.000Z"
}
]
}
```
### 获取单个实例详情
```http
GET /api/instances/:instanceId
Authorization: Bearer <token>
```
### 启用/禁用实例
```http
PUT /api/instances/:instanceId
Authorization: Bearer <token>
Content-Type: application/json
{
"enabled": false
}
```
---
## 任务管理
### 获取任务列表
```http
GET /api/tasks?status=pending&limit=20&offset=0
Authorization: Bearer <token>
```
查询参数:
- `status`: 任务状态 (pending/running/completed/failed)
- `limit`: 分页数量
- `offset`: 分页偏移
响应:
```json
{
"code": 200,
"data": {
"total": 100,
"items": [
{
"id": "task-uuid",
"instanceId": "127.0.0.1:8001",
"status": "running",
"progress": 50,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
]
}
}
```
### 提交任务
```http
POST /api/tasks
Authorization: Bearer <token>
Content-Type: application/json
{
"workflow": {},
"params": {}
}
```
### 获取任务详情
```http
GET /api/tasks/:taskId
Authorization: Bearer <token>
```
### 取消任务
```http
DELETE /api/tasks/:taskId
Authorization: Bearer <token>
```
---
## 文件管理
### 上传文件
```http
POST /api/files/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: [二进制文件]
```
响应:
```json
{
"code": 200,
"data": {
"fileId": "file-uuid",
"filename": "image.png",
"url": "https://shuzhiren.xueai.art/upload/file/xxx"
}
}
```
### 获取文件列表
```http
GET /api/files
Authorization: Bearer <token>
```
### 删除文件
```http
DELETE /api/files/:fileId
Authorization: Bearer <token>
```
---
## 配置管理
### 获取配置
```http
GET /api/config
Authorization: Bearer <token>
```
### 更新配置
```http
PUT /api/config
Authorization: Bearer <token>
Content-Type: application/json
{
"servers": [...],
"healthCheck": {
"interval": 30000
}
}
```
---
## 监控
### 获取监控概览
```http
GET /api/monitor/overview
Authorization: Bearer <token>
```
响应:
```json
{
"code": 200,
"data": {
"totalInstances": 8,
"aliveInstances": 8,
"busyInstances": 2,
"pendingTasks": 5,
"runningTasks": 2,
"completedTasks": 100
}
}
```
---
## WebSocket 消息协议
### 连接
```
ws://localhost:3000/ws
```
### 消息格式
所有消息遵循以下格式:
```json
{
"type": "message_type",
"timestamp": 1704067200000,
"data": {},
"requestId": "optional-uuid"
}
```
### 服务器 → 客户端 消息类型
| type | 说明 |
|------|------|
| `instance_status` | 实例状态变更 |
| `task_progress` | 任务进度更新 |
| `task_completed` | 任务完成 |
| `task_failed` | 任务失败 |
| `global_status` | 全局状态同步 |
### 实例状态变更消息
```json
{
"type": "instance_status",
"data": {
"instanceId": "127.0.0.1:8001",
"alive": true,
"busy": false
}
}
```
### 任务进度消息
```json
{
"type": "task_progress",
"data": {
"taskId": "task-uuid",
"progress": 50
}
}
```
### 任务完成消息
```json
{
"type": "task_completed",
"data": {
"taskId": "task-uuid",
"result": {
"files": ["https://..."]
}
}
}
```
---
## 健康检查
```http
GET /api/health
```
响应:
```json
{
"code": 200,
"data": {
"status": "ok",
"redis": "connected"
}
}
```

1624
ComfyUI_API_Documentation.md Normal file

File diff suppressed because it is too large Load Diff

183
README.md Normal file
View File

@ -0,0 +1,183 @@
# ComfyUI 实例集群通信中间层
多服务器 ComfyUI 实例集群的专用通信桥梁,实现统一状态管理、任务转发与结果回传。
## 项目特点
- 多服务器分布式部署支持
- 实时实例状态监控
- WebSocket 双向通信
- 任务精准转发与结果回传
- 专业的可视化管理界面
- 热更新配置支持
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Node.js + Express |
| 前端 | Vue 3 + Element Plus + Vite |
| 数据存储 | Redis |
| 通信 | WebSocket (ws) + Axios |
## 快速开始
### 环境要求
- Node.js >= 16
- Redis >= 6
- 可访问的 ComfyUI 实例
### 1. 安装依赖
```bash
# 根目录
cd d:\WebUI\Kexue\comfyui\comfyui-cluster-bridge
# 安装后端依赖
cd backend
npm install
# 安装前端依赖
cd ../frontend
npm install
```
### 2. 配置
#### 2.1 环境变量
复制并编辑 `.env` 文件:
```bash
cd backend
cp .env.example .env
# 编辑 .env 文件,配置 Redis、JWT 密钥等
```
#### 2.2 服务器配置
编辑 `backend/config/servers.json` 文件,配置 ComfyUI 实例:
```json
{
"servers": [
{
"id": "server-1",
"name": "主服务器",
"ip": "127.0.0.1",
"enabled": true,
"instances": [
{ "port": 8001, "enabled": true },
{ "port": 8002, "enabled": true }
]
}
]
}
```
### 3. 启动 Redis
```bash
# Windows
redis-server
# 或使用 Docker
docker run -d -p 6379:6379 redis:latest
```
### 4. 启动服务
```bash
# 开发模式(同时启动后端和前端)
cd d:\WebUI\Kexue\comfyui\comfyui-cluster-bridge
npm run dev
# 或分别启动
# 后端 (端口 3000)
cd backend
npm run dev
# 前端 (端口 5173)
cd frontend
npm run dev
```
### 5. 访问管理界面
打开浏览器访问http://localhost:5173
默认登录账号:
- 用户名:`admin`
- 密码:`admin123`
## 目录结构
```
comfyui-cluster-bridge/
├── backend/ # 后端服务
│ ├── src/
│ │ ├── index.js # 主入口
│ │ ├── config/ # 配置模块
│ │ ├── logger/ # 日志模块
│ │ ├── cluster-manager/# 集群管理
│ │ ├── websocket-client/# WebSocket 通信
│ │ ├── task-forwarder/ # 任务转发
│ │ ├── file-uploader/ # 文件上传
│ │ └── admin-api/ # 管理 API
│ ├── config/ # 配置文件
│ ├── logs/ # 日志目录
│ └── uploads/ # 上传文件目录
├── frontend/ # 前端管理界面
│ ├── src/
│ │ ├── views/ # 页面组件
│ │ ├── stores/ # Pinia 状态
│ │ ├── router/ # 路由配置
│ │ └── api/ # API 封装
│ └── index.html
└── README.md
```
## API 文档
详细的接口文档请参考 [API.md](./API.md)。
## 配置说明
### 服务器配置 (servers.json)
| 字段 | 类型 | 说明 |
|------|------|------|
| servers | array | 服务器列表 |
| servers[].id | string | 服务器唯一标识 |
| servers[].name | string | 服务器名称 |
| servers[].ip | string | 服务器 IP 地址 |
| servers[].enabled | boolean | 是否启用 |
| servers[].instances | array | ComfyUI 实例列表 |
| servers[].instances[].port | number | 实例端口 |
| servers[].instances[].enabled | boolean | 是否启用 |
| healthCheck.interval | number | 健康检查间隔 (毫秒) |
| healthCheck.timeout | number | 健康检查超时 (毫秒) |
| taskQueue.websocketUrl | string | 任务队列 WebSocket 地址 |
| upload.url | string | 文件上传接口地址 |
## 常见问题
### Redis 连接失败
确保 Redis 服务已启动,检查 `.env` 文件中的 `REDIS_HOST``REDIS_PORT` 配置。
### ComfyUI 实例离线
检查:
1. ComfyUI 服务是否正常运行
2. 端口是否可访问
3. 防火墙配置
### 任务队列 WebSocket 连接失败
检查 `servers.json` 中的 `taskQueue.websocketUrl` 配置是否正确。
## 许可证
MIT

10
backend/.env Normal file
View File

@ -0,0 +1,10 @@
PORT=3000
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
JWT_SECRET=comfyui-cluster-bridge-secret-key-2024
JWT_EXPIRES_IN=24h
ADMIN_USERNAME=admin
ADMIN_PASSWORD=2233..2233
MESSAGE_DISPATCHER_URL=ws://localhost:4000/ws
BRIDGE_ID=bridge-1

View File

@ -0,0 +1,30 @@
{
"servers": [
{
"id": "server-1",
"name": "主服务器",
"ip": "127.0.0.1",
"enabled": true,
"instances": [
{ "port": 8190, "enabled": true },
{ "port": 8191, "enabled": true },
{ "port": 8192, "enabled": true },
{ "port": 8193, "enabled": true },
{ "port": 8194, "enabled": true },
{ "port": 8195, "enabled": true },
{ "port": 8196, "enabled": true },
{ "port": 8197, "enabled": true }
]
}
],
"healthCheck": {
"interval": 30000,
"timeout": 3000
},
"taskQueue": {
"websocketUrl": "ws://localhost:8080/ws"
},
"upload": {
"url": "https://shuzhiren.xueai.art/upload/file"
}
}

View File

@ -0,0 +1,306 @@
# 任务队列后端改造提示词
## 概述
你需要改造现有的任务队列后端,使其能够与 ComfyUI Cluster Bridge桥接器建立并维持可靠的双向通信。桥接器位于任务队列和 ComfyUI 后端之间,负责任务的转发和结果的回调。
## 通信架构
```
客户端 → 任务队列后端 ←(WebSocket)→ 桥接器 → ComfyUI后端
↑_______________________________(Webhook回调)_____|
```
## 核心要求
### 1. WebSocket 服务器
任务队列后端需要实现一个 WebSocket 服务器,桥接器会主动连接到该服务器。
#### 连接地址配置
- WebSocket 路径建议:`/ws`
- 桥接器可通过后台管理界面修改连接地址
- 默认配置:`ws://localhost:8080/ws`
#### 连接生命周期
- 桥接器启动时会主动连接
- 连接断开后,桥接器每分钟尝试重连一次,直至成功
- 任务队列后端应支持多个桥接器同时连接
### 2. 消息协议
#### 消息通用格式
所有消息采用 JSON 格式,结构如下:
```json
{
"type": "消息类型",
"data": { ... }
}
```
#### 桥接器 → 任务队列(桥接器发送的消息)
##### REGISTER - 注册消息
桥接器连接成功后立即发送:
```json
{
"type": "REGISTER",
"data": {
"bridgeId": "uuid字符串",
"instanceCount": 8,
"availableInstanceCount": 5,
"timestamp": "2024-01-01T00:00:00.000Z"
}
}
```
##### HEARTBEAT - 心跳消息
桥接器定期发送建议每30秒一次
```json
{
"type": "HEARTBEAT",
"data": {
"instanceCount": 8,
"availableInstanceCount": 5,
"busyInstanceCount": 3,
"timestamp": "2024-01-01T00:00:00.000Z"
}
}
```
##### TASK_ACK - 任务确认消息
桥接器收到任务后立即发送:
```json
{
"type": "TASK_ACK",
"data": {
"code": 0,
"msg": "success",
"data": {
"taskId": "1910246754753896450",
"taskStatus": "RUNNING"
}
}
}
```
##### PONG - 心跳响应
响应任务队列的 PING 消息:
```json
{
"type": "PONG"
}
```
#### 任务队列 → 桥接器(任务队列发送的消息)
##### TASK_ASSIGN - 任务分配消息
```json
{
"type": "TASK_ASSIGN",
"data": {
"workflowId": "1904136902449209346",
"nodeInfoList": [
{
"nodeId": "6",
"fieldName": "text",
"fieldValue": "1 girl in classroom"
},
{
"nodeId": "3",
"fieldName": "seed",
"fieldValue": "1231231"
}
],
"webhookUrl": "https://shuzhiren.xueai.art/callback"
}
}
```
##### PING - 心跳检测
任务队列可定期发送:
```json
{
"type": "PING"
}
```
### 3. Webhook 回调接口
任务队列后端需要提供一个 HTTP POST 接口用于接收任务完成回调。
#### 回调请求格式
```
POST {webhookUrl}
Content-Type: application/json
{
"event": "TASK_END",
"taskId": "1910246754753896450",
"eventData": "{\"code\":0,\"msg\":\"success\",\"data\":[{\"fileUrl\":\"https://example.com/image.png\",\"fileType\":\"png\",\"taskCostTime\":0,\"nodeId\":\"9\"}]}"
}
```
#### eventData 字段说明
`eventData` 是 JSON 字符串,解析后结构如下:
**成功时:**
```json
{
"code": 0,
"msg": "success",
"data": [
{
"fileUrl": "https://example.com/output.png",
"fileType": "png",
"taskCostTime": 0,
"nodeId": "9"
}
]
}
```
**失败时:**
```json
{
"code": 1,
"msg": "错误信息",
"data": []
}
```
### 4. 任务调度逻辑
任务队列应根据桥接器的可用实例数量来分配任务:
1. **桥接器注册时**:记录该桥接器的总实例数和可用实例数
2. **心跳更新时**:更新桥接器的实例状态
3. **任务分配时**
- 选择有可用实例的桥接器
- 根据 `availableInstanceCount` 决定发送多少任务
- 发送任务后,暂减该桥接器的可用计数
- 收到 TASK_ACK 或 TASK_END 回调后更新状态
### 5. 错误处理
#### WebSocket 连接错误
- 桥接器断开连接后,任务队列应:
- 标记该桥接器为离线
- 停止向其发送新任务
- 已发送但未完成的任务根据需要重新分配
#### 任务超时
- 任务队列应设置任务超时机制
- 超时任务可标记为失败或尝试重新分配
- 超时时间建议5-10分钟
#### Webhook 回调失败
- 桥接器会尝试发送回调,但可能失败
- 任务队列应考虑实现回调重试机制
- 或提供查询任务状态的接口供桥接器轮询
## 实施步骤
### 第一步:实现 WebSocket 服务器
1. 创建 WebSocket 服务器,监听指定端口
2. 实现连接管理,支持多个桥接器同时连接
3. 实现消息解析和分发
### 第二步:实现注册和心跳机制
1. 处理 REGISTER 消息,记录桥接器信息
2. 处理 HEARTBEAT 消息,更新桥接器状态
3. 实现桥接器离线检测如超过3个心跳周期未收到消息
### 第三步:实现任务分配
1. 创建任务队列存储
2. 实现任务分配逻辑,根据可用实例选择桥接器
3. 发送 TASK_ASSIGN 消息
4. 处理 TASK_ACK 确认消息
### 第四步:实现 Webhook 回调接口
1. 创建 HTTP POST 接口接收回调
2. 解析 eventData 字段
3. 更新任务状态
4. 触发后续业务逻辑
### 第五步:实现错误处理和重试
1. 实现桥接器离线后的任务重分配
2. 实现任务超时机制
3. 添加监控和日志
## API 接口定义(任务队列后端)
### WebSocket 端点
| 路径 | 方法 | 说明 |
|------|------|------|
| `/ws` | WebSocket | 桥接器连接端点 |
### HTTP 端点
| 路径 | 方法 | 说明 |
|------|------|------|
| `/callback` | POST | 接收任务完成回调 |
## 消息结构速查表
### 任务队列发送的消息
| 类型 | 说明 | 触发时机 |
|------|------|----------|
| TASK_ASSIGN | 分配任务 | 有新任务需要处理时 |
| PING | 心跳检测 | 定期发送 |
### 桥接器发送的消息
| 类型 | 说明 | 触发时机 |
|------|------|----------|
| REGISTER | 注册桥接器 | 连接成功后立即 |
| HEARTBEAT | 心跳 | 定期发送30秒 |
| TASK_ACK | 任务确认 | 收到任务后立即 |
| PONG | 心跳响应 | 收到 PING 后 |
## 集成测试流程
### 测试 1连接和注册
1. 启动任务队列后端
2. 启动桥接器
3. 验证桥接器成功连接并发送 REGISTER 消息
4. 验证任务队列正确记录桥接器信息
### 测试 2任务分配和回调
1. 通过任务队列提交一个测试任务
2. 验证任务被分配到桥接器TASK_ASSIGN
3. 验证桥接器发送 TASK_ACK
4. 等待任务完成
5. 验证收到 Webhook 回调
6. 验证回调数据格式正确
### 测试 3断开重连
1. 建立连接后,断开桥接器
2. 验证任务队列检测到离线
3. 等待1分钟验证桥接器自动重连
4. 验证重连后正常工作
### 测试 4多实例负载
1. 配置桥接器有多个 ComfyUI 实例
2. 同时提交多个任务
3. 验证任务按实例数量分配
4. 验证所有任务正常完成
## 注意事项
1. **webhookUrl 的传递**:任务队列必须在 TASK_ASSIGN 消息中提供 `webhookUrl`,桥接器通过该 URL 发送回调
2. **taskId 的对应**TASK_ACK 和回调中的 taskId 应与任务队列中的任务标识对应
3. **eventData 的格式**eventData 是 JSON 字符串,需要先解析再使用
4. **可用性监控**:任务队列应监控桥接器的在线状态,避免向离线桥接器发送任务
5. **幂等性**:考虑实现回调接口的幂等性,防止重复处理

24
backend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "comfyui-cluster-bridge-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"axios": "^1.6.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"form-data": "^4.0.0",
"ioredis": "^5.3.2",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"ws": "^8.14.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

1435
backend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,293 @@
/**
* cluster-manager模块 - 集群管理
* 负责实例存活检测状态管理和负载均衡
*/
import config from '../config/index.js';
import logger from '../logger/index.js';
import axios from 'axios';
import comfyUIMonitor from '../comfyui-monitor/index.js';
class ClusterManager {
constructor() {
this.instances = new Map();
this.healthCheckInterval = null;
this.roundRobinIndex = 0;
this.init();
}
/**
* 初始化集群管理器
*/
init() {
const initialInstances = config.getAllInstances();
for (const instance of initialInstances) {
this.instances.set(instance.id, instance);
}
this.startHealthCheck();
logger.info(`集群管理器初始化完成,共 ${this.instances.size} 个实例`);
}
/**
* 启动健康检查
*/
startHealthCheck() {
const interval = config.get('healthCheck.interval', 30000);
const timeout = config.get('healthCheck.timeout', 3000);
this.healthCheckInterval = setInterval(async () => {
for (const [instanceId, instance] of this.instances) {
await this.checkInstanceHealth(instanceId, timeout);
}
}, interval);
logger.info(`健康检查已启动,间隔: ${interval}ms`);
}
/**
* 停止健康检查
*/
stopHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
logger.info('健康检查已停止');
}
}
/**
* 检查实例健康状态
* @param {string} instanceId - 实例ID
* @param {number} timeout - 超时时间(ms)
*/
async checkInstanceHealth(instanceId, timeout = 3000) {
const instance = this.instances.get(instanceId);
if (!instance) return;
const instanceConfig = {
serverId: instance.serverId,
serverName: instance.serverName,
ip: instance.ip,
port: instance.port,
apiUrl: instance.apiUrl,
wsUrl: instance.wsUrl
};
try {
const response = await axios.get(`${instance.apiUrl}/system_stats`, {
timeout
});
const oldStatus = instance.status;
instance.status = 'online';
instance.lastHeartbeat = new Date().toISOString();
if (response.data) {
instance.load = response.data.system_load || 0;
}
const stateChange = comfyUIMonitor.setInstanceState(instanceId, 'online');
if (stateChange) {
comfyUIMonitor.logConnectionStateChange(
instanceId,
stateChange.oldState,
'online',
'健康检查成功',
instanceConfig
);
}
} catch (error) {
const wasOnline = instance.status === 'online' || instance.status === 'busy';
const oldStatus = instance.status;
instance.status = 'offline';
const stateChange = comfyUIMonitor.setInstanceState(instanceId, 'offline');
if (stateChange || wasOnline) {
const disconnectReason = error.code === 'ECONNREFUSED'
? '连接被拒绝'
: error.code === 'ECONNABORTED'
? '连接超时'
: error.message || '未知错误';
comfyUIMonitor.logConnectionStateChange(
instanceId,
oldStatus,
'offline',
disconnectReason,
instanceConfig
);
comfyUIMonitor.logConnectionError(instanceId, error, instanceConfig);
}
}
}
/**
* 获取所有实例
* @returns {Array} 实例列表
*/
getAllInstances() {
return Array.from(this.instances.values());
}
/**
* 获取在线实例
* @returns {Array} 在线实例列表
*/
getOnlineInstances() {
return Array.from(this.instances.values()).filter(
instance => instance.status === 'online'
);
}
/**
* 获取实例详情
* @param {string} instanceId - 实例ID
* @returns {object|null} 实例信息
*/
getInstance(instanceId) {
return this.instances.get(instanceId) || null;
}
/**
* 更新实例状态
* @param {string} instanceId - 实例ID
* @param {string} status - 状态
*/
updateInstanceStatus(instanceId, status) {
const instance = this.instances.get(instanceId);
if (instance) {
instance.status = status;
if (status === 'busy') {
instance.currentTasks++;
} else if (status === 'online' && instance.currentTasks > 0) {
instance.currentTasks--;
}
}
}
/**
* 选择实例负载均衡 - 轮询
* @returns {object|null} 选中的实例
*/
selectInstance() {
const onlineInstances = this.getOnlineInstances();
if (onlineInstances.length === 0) {
logger.error('没有可用的在线实例');
return null;
}
const instance = onlineInstances[this.roundRobinIndex % onlineInstances.length];
this.roundRobinIndex++;
logger.debug(`选择实例: ${instance.id} (${instance.apiUrl})`);
return instance;
}
/**
* 重新加载配置
*/
reloadConfig() {
comfyUIMonitor.logConfigReload('cluster-manager');
const oldInstances = new Map(this.instances);
config.loadConfig();
const newInstances = config.getAllInstances();
const newInstanceIds = new Set(newInstances.map(i => i.id));
for (const [oldId, oldInstance] of oldInstances) {
if (!newInstanceIds.has(oldId)) {
const oldConfig = {
serverId: oldInstance.serverId,
serverName: oldInstance.serverName,
ip: oldInstance.ip,
port: oldInstance.port
};
comfyUIMonitor.logInstanceRemoved(oldId, oldConfig);
this.instances.delete(oldId);
}
}
for (const instance of newInstances) {
if (!this.instances.has(instance.id)) {
const newConfig = {
serverId: instance.serverId,
serverName: instance.serverName,
ip: instance.ip,
port: instance.port
};
comfyUIMonitor.logInstanceAdded(instance.id, newConfig);
this.instances.set(instance.id, instance);
} else {
const oldInstance = oldInstances.get(instance.id);
if (oldInstance && oldInstance.port !== instance.port) {
const configData = {
serverId: instance.serverId,
serverName: instance.serverName,
ip: instance.ip
};
comfyUIMonitor.logPortChange(
instance.id,
oldInstance.port,
instance.port,
'配置重新加载',
configData
);
this.instances.set(instance.id, instance);
}
}
}
logger.info('集群配置已重新加载');
}
/**
* 主动检查指定实例的健康状态
* @param {string} instanceId - 实例ID
* @returns {Promise<object>} 实例状态
*/
async checkInstanceHealthNow(instanceId) {
const timeout = config.get('healthCheck.timeout', 3000);
await this.checkInstanceHealth(instanceId, timeout);
const instance = this.instances.get(instanceId);
return instance || null;
}
/**
* 主动检查所有离线实例的健康状态
* @returns {Promise<Array>} 检查后的实例列表
*/
async checkOfflineInstancesHealth() {
const timeout = config.get('healthCheck.timeout', 3000);
const checkPromises = [];
for (const [instanceId, instance] of this.instances) {
if (instance.status === 'offline') {
checkPromises.push(this.checkInstanceHealth(instanceId, timeout));
}
}
await Promise.allSettled(checkPromises);
return this.getAllInstances();
}
/**
* 主动检查所有实例的健康状态
* @returns {Promise<Array>} 检查后的实例列表
*/
async checkAllInstancesHealth() {
const timeout = config.get('healthCheck.timeout', 3000);
const checkPromises = [];
for (const [instanceId] of this.instances) {
checkPromises.push(this.checkInstanceHealth(instanceId, timeout));
}
await Promise.allSettled(checkPromises);
return this.getAllInstances();
}
}
export default new ClusterManager();

View File

@ -0,0 +1,88 @@
import logger from '../logger/index.js';
import EventEmitter from 'events';
const LOG_PREFIX = '[ComfyUI Monitor]';
class ComfyUIMonitor extends EventEmitter {
constructor() {
super();
this.instanceStates = new Map();
this.configWatchers = new Map();
this.isWatching = false;
}
formatTimestamp() {
return new Date().toISOString();
}
logConnectionStateChange(instanceId, oldState, newState, reason = '', config = {}) {
const timestamp = this.formatTimestamp();
const message = `${LOG_PREFIX} [${timestamp}] 连接状态变更 | 实例ID: ${instanceId} | 原状态: ${oldState} | 新状态: ${newState} | 原因: ${reason || '未知'} | 配置: ${JSON.stringify(config)}`;
if (newState === 'offline' || newState === 'disconnected') {
logger.warn(message);
} else if (newState === 'online' || newState === 'connected') {
logger.info(message);
} else {
logger.debug(message);
}
this.emit('connectionStateChange', { instanceId, oldState, newState, reason, config, timestamp });
}
logPortChange(instanceId, oldPort, newPort, triggerSource = 'unknown', config = {}) {
const timestamp = this.formatTimestamp();
const message = `${LOG_PREFIX} [${timestamp}] 端口配置变更 | 实例ID: ${instanceId} | 旧端口: ${oldPort} | 新端口: ${newPort} | 触发源: ${triggerSource} | 配置: ${JSON.stringify(config)}`;
logger.info(message);
this.emit('portChange', { instanceId, oldPort, newPort, triggerSource, config, timestamp });
}
logConnectionError(instanceId, error, config = {}) {
const timestamp = this.formatTimestamp();
const errorMessage = error.message || String(error);
const errorCode = error.code || 'UNKNOWN';
const message = `${LOG_PREFIX} [${timestamp}] 连接错误 | 实例ID: ${instanceId} | 错误代码: ${errorCode} | 错误: ${errorMessage} | 配置: ${JSON.stringify(config)}`;
logger.error(message);
this.emit('connectionError', { instanceId, error, config, timestamp });
}
logConfigReload(source = 'unknown') {
const timestamp = this.formatTimestamp();
const message = `${LOG_PREFIX} [${timestamp}] 配置重新加载 | 触发源: ${source}`;
logger.info(message);
this.emit('configReload', { source, timestamp });
}
logInstanceAdded(instanceId, config = {}) {
const timestamp = this.formatTimestamp();
const message = `${LOG_PREFIX} [${timestamp}] 实例新增 | 实例ID: ${instanceId} | 配置: ${JSON.stringify(config)}`;
logger.info(message);
this.emit('instanceAdded', { instanceId, config, timestamp });
}
logInstanceRemoved(instanceId, config = {}) {
const timestamp = this.formatTimestamp();
const message = `${LOG_PREFIX} [${timestamp}] 实例移除 | 实例ID: ${instanceId} | 配置: ${JSON.stringify(config)}`;
logger.info(message);
this.emit('instanceRemoved', { instanceId, config, timestamp });
}
setInstanceState(instanceId, state) {
const oldState = this.instanceStates.get(instanceId);
if (oldState !== state) {
this.instanceStates.set(instanceId, state);
return { oldState: oldState || 'unknown', newState: state };
}
return null;
}
getInstanceState(instanceId) {
return this.instanceStates.get(instanceId) || 'unknown';
}
}
export default new ComfyUIMonitor();

271
backend/src/config/index.js Normal file
View File

@ -0,0 +1,271 @@
/**
* config模块 - 配置管理
* 负责加载和管理应用配置
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import comfyUIMonitor from '../comfyui-monitor/index.js';
import jsonPersistence from '../json-persistence/index.js';
import logger from '../logger/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class ConfigManager {
constructor() {
this.config = {};
this.configPath = path.resolve(__dirname, '../../config/servers.json');
this.watcher = null;
this.previousInstances = [];
this.loadConfig();
this.startFileWatcher();
}
/**
* 加载配置文件
*/
loadConfig() {
try {
if (fs.existsSync(this.configPath)) {
const configData = fs.readFileSync(this.configPath, 'utf-8');
this.config = JSON.parse(configData);
} else {
const examplePath = path.resolve(__dirname, '../../config/servers.example.json');
if (fs.existsSync(examplePath)) {
const exampleData = fs.readFileSync(examplePath, 'utf-8');
this.config = JSON.parse(exampleData);
} else {
this.config = this.getDefaultConfig();
}
}
} catch (error) {
console.error('加载配置文件失败:', error);
this.config = this.getDefaultConfig();
}
}
/**
* 获取默认配置
*/
getDefaultConfig() {
return {
servers: [],
healthCheck: {
interval: 30000,
timeout: 3000
},
messageDispatcher: {
websocketUrl: process.env.MESSAGE_DISPATCHER_URL || 'ws://localhost:4000/ws',
bridgeId: process.env.BRIDGE_ID || 'bridge-1'
},
upload: {
url: 'https://shuzhiren.xueai.art/upload/file'
}
};
}
/**
* 获取配置项
* @param {string} key - 配置键
* @param {*} defaultValue - 默认值
* @returns {*} 配置值
*/
get(key, defaultValue = null) {
const keys = key.split('.');
let value = this.config;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return defaultValue;
}
}
return value;
}
/**
* 更新配置项
* @param {string} key - 配置键
* @param {*} value - 配置值
*/
set(key, value) {
const keys = key.split('.');
let config = this.config;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in config)) {
config[k] = {};
}
config = config[k];
}
config[keys[keys.length - 1]] = value;
}
/**
* 保存配置到文件
*/
saveConfig(operator = 'system') {
try {
const oldConfig = JSON.parse(JSON.stringify(this.config));
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tempPath = `${this.configPath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(this.config, null, 2), 'utf-8');
fs.renameSync(tempPath, this.configPath);
this.logConfigChange(oldConfig, this.config, operator);
jsonPersistence.saveConfigSnapshot(this.config);
return true;
} catch (error) {
logger.error('[Config] 保存配置文件失败:', error);
return false;
}
}
logConfigChange(oldConfig, newConfig, operator) {
const changeLog = {
timestamp: new Date().toISOString(),
operator,
oldConfig,
newConfig
};
logger.info(`[Config] 配置已变更 | 操作人: ${operator} | 时间: ${changeLog.timestamp}`);
}
/**
* 获取所有配置
* @returns {object} 完整配置对象
*/
getAll() {
return { ...this.config };
}
/**
* 获取所有ComfyUI实例列表
* @returns {Array} 实例列表
*/
getAllInstances() {
const instances = [];
const servers = this.get('servers', []);
for (const server of servers) {
if (!server.enabled) continue;
for (const instance of server.instances) {
if (!instance.enabled) continue;
instances.push({
id: `${server.id}-${instance.port}`,
serverId: server.id,
serverName: server.name,
ip: server.ip,
port: instance.port,
wsUrl: `ws://${server.ip}:${instance.port}/ws`,
apiUrl: `http://${server.ip}:${instance.port}`,
status: 'offline',
load: 0,
currentTasks: 0,
lastHeartbeat: null
});
}
}
return instances;
}
startFileWatcher() {
try {
this.watcher = fs.watch(this.configPath, (eventType) => {
if (eventType === 'change') {
this.handleConfigChange();
}
});
this.previousInstances = this.getAllInstances();
} catch (error) {
console.error('启动配置文件监听失败:', error);
}
}
stopFileWatcher() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
handleConfigChange() {
try {
const oldInstances = [...this.previousInstances];
this.loadConfig();
const newInstances = this.getAllInstances();
comfyUIMonitor.logConfigReload('config-file-change');
this.detectPortChanges(oldInstances, newInstances);
this.previousInstances = newInstances;
} catch (error) {
console.error('处理配置变更失败:', error);
}
}
detectPortChanges(oldInstances, newInstances) {
const oldMap = new Map();
const newMap = new Map();
for (const inst of oldInstances) {
oldMap.set(`${inst.serverId}-${inst.port}`, inst);
}
for (const inst of newInstances) {
newMap.set(`${inst.serverId}-${inst.port}`, inst);
}
for (const oldInst of oldInstances) {
const serverNewInstances = newInstances.filter(
ni => ni.serverId === oldInst.serverId
);
const hasMatchingServer = serverNewInstances.some(
ni => ni.ip === oldInst.ip && ni.port !== oldInst.port
);
if (hasMatchingServer) {
for (const newInst of serverNewInstances) {
if (newInst.ip === oldInst.ip && newInst.port !== oldInst.port) {
const configData = {
serverId: oldInst.serverId,
serverName: oldInst.serverName,
ip: oldInst.ip
};
const oldId = `${oldInst.serverId}-${oldInst.port}`;
const newId = `${newInst.serverId}-${newInst.port}`;
comfyUIMonitor.logPortChange(
oldId,
oldInst.port,
newInst.port,
'配置文件变更',
configData
);
}
}
}
}
}
}
export default new ConfigManager();

View File

@ -0,0 +1,58 @@
import logger from '../logger/index.js';
import redisManager from '../redis-manager/index.js';
import jsonPersistence from '../json-persistence/index.js';
import clusterManager from '../cluster-manager/index.js';
class DataSyncManager {
constructor() {
this.syncInterval = null;
this.init();
}
init() {
this.startPeriodicSync();
logger.info('[Data Sync] 数据同步管理器已初始化');
}
startPeriodicSync() {
this.syncInterval = setInterval(() => {
this.syncAll().catch(err => {
logger.error('[Data Sync] 定期同步失败:', err);
});
}, 300000);
}
stopPeriodicSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
}
async syncAll() {
logger.info('[Data Sync] 开始数据同步...');
await this.syncInstanceStates();
logger.info('[Data Sync] 数据同步完成');
}
async syncInstanceStates() {
const instances = clusterManager.getAllInstances();
const stateData = {
syncedAt: new Date().toISOString(),
instances: instances.map(inst => ({
id: inst.id,
status: inst.status,
load: inst.load,
currentTasks: inst.currentTasks,
lastHeartbeat: inst.lastHeartbeat
}))
};
await jsonPersistence.save('cluster', 'state', stateData, 'system');
logger.debug('[Data Sync] 实例状态已同步到JSON文件');
}
}
export default new DataSyncManager();

View File

@ -0,0 +1,148 @@
/**
* file-uploader模块 - 文件上传处理
*/
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';
import logger from '../logger/index.js';
import config from '../config/index.js';
import axios from 'axios';
import FormData from 'form-data';
const uploadDir = path.resolve(process.cwd(), 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 100 * 1024 * 1024
}
});
class FileUploader {
constructor() {
this.files = new Map();
}
/**
* 获取multer上传中间件
*/
getUploadMiddleware() {
return upload.single('file');
}
/**
* 处理文件上传
* @param {object} file - 文件对象
* @returns {object} 文件信息
*/
async uploadFile(file) {
const fileId = uuidv4();
const fileInfo = {
id: fileId,
filename: file.originalname,
path: file.path,
size: file.size,
mimetype: file.mimetype,
uploadedAt: new Date().toISOString()
};
this.files.set(fileId, fileInfo);
logger.info(`文件已上传: ${fileId} - ${file.originalname}`);
return fileInfo;
}
/**
* 上传文件到外部服务器
* @param {string} filePath - 文件路径
* @param {string} originalName - 原始文件名
* @returns {object} 上传结果
*/
async uploadToExternalServer(filePath, originalName) {
const uploadUrl = config.get('upload.url', 'https://shuzhiren.xueai.art/upload/file');
const formData = new FormData();
formData.append('file', fs.createReadStream(filePath), {
filename: originalName
});
const response = await axios.post(uploadUrl, formData, {
headers: formData.getHeaders(),
maxContentLength: Infinity,
maxBodyLength: Infinity
});
logger.info(`文件已上传到外部服务器: ${originalName}`);
return response.data;
}
/**
* 获取文件信息
* @param {string} fileId - 文件ID
* @returns {object|null} 文件信息
*/
getFile(fileId) {
return this.files.get(fileId) || null;
}
/**
* 获取文件列表
* @returns {Array} 文件列表
*/
getFiles() {
return Array.from(this.files.values());
}
/**
* 删除文件
* @param {string} fileId - 文件ID
* @returns {boolean} 是否成功
*/
deleteFile(fileId) {
const fileInfo = this.files.get(fileId);
if (!fileInfo) {
return false;
}
if (fs.existsSync(fileInfo.path)) {
fs.unlinkSync(fileInfo.path);
}
this.files.delete(fileId);
logger.info(`文件已删除: ${fileId}`);
return true;
}
/**
* 清理过期文件
* @param {number} maxAgeHours - 最大保留时间(小时)
*/
cleanupOldFiles(maxAgeHours = 24) {
const now = Date.now();
const maxAge = maxAgeHours * 60 * 60 * 1000;
for (const [fileId, fileInfo] of this.files) {
const age = now - new Date(fileInfo.uploadedAt).getTime();
if (age > maxAge) {
this.deleteFile(fileId);
}
}
}
}
export default new FileUploader();

37
backend/src/index.js Normal file
View File

@ -0,0 +1,37 @@
import express from 'express';
import cors from 'cors';
import 'dotenv/config';
import clusterManager from './cluster-manager/index.js';
import logger from './logger/index.js';
import redisManager from './redis-manager/index.js';
import jsonPersistence from './json-persistence/index.js';
import dataSyncManager from './data-sync/index.js';
import taskQueueClient from './task-queue-client/index.js';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.get('/', (req, res) => {
res.json({
name: 'ComfyUI Cluster Bridge',
version: '1.0.0',
status: 'running',
role: 'bridge',
timestamp: new Date().toISOString()
});
});
app.listen(PORT, () => {
console.log('========================================');
console.log('ComfyUI Cluster Bridge 已启动');
console.log(`服务地址: http://localhost:${PORT}`);
console.log('角色: 桥接器后端 (连接统一消息分发后端)');
console.log('========================================');
taskQueueClient.start();
});

View File

@ -0,0 +1,121 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import logger from '../logger/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class JsonPersistence {
constructor() {
this.baseDir = path.resolve(__dirname, '../../data');
this.ensureBaseDir();
}
ensureBaseDir() {
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
generateChecksum(data) {
return crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
}
getFilePath(module, dataType, timestamp = null) {
const ts = timestamp || Date.now();
const fileName = `${module}_${dataType}_${ts}.json`;
return path.join(this.baseDir, fileName);
}
async writeAtomic(filePath, data) {
const tempPath = `${filePath}.tmp`;
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
fs.renameSync(tempPath, filePath);
}
async save(module, dataType, data, operator = 'system') {
const timestamp = Date.now();
const checksum = this.generateChecksum(data);
const fileData = {
metadata: {
module,
dataType,
createdAt: new Date(timestamp).toISOString(),
version: '1.0.0',
checksum,
operator
},
data
};
const filePath = this.getFilePath(module, dataType, timestamp);
await this.writeAtomic(filePath, fileData);
logger.info(`[JSON Persistence] 数据已保存: ${filePath}`);
return filePath;
}
async load(module, dataType, filePath = null) {
let targetPath = filePath;
if (!targetPath) {
const files = fs.readdirSync(this.baseDir)
.filter(f => f.startsWith(`${module}_${dataType}_`))
.sort()
.reverse();
if (files.length === 0) {
return null;
}
targetPath = path.join(this.baseDir, files[0]);
}
if (!fs.existsSync(targetPath)) {
return null;
}
const content = fs.readFileSync(targetPath, 'utf-8');
const fileData = JSON.parse(content);
const calculatedChecksum = this.generateChecksum(fileData.data);
if (fileData.metadata.checksum !== calculatedChecksum) {
logger.warn(`[JSON Persistence] 数据校验失败: ${targetPath}`);
}
return fileData.data;
}
async saveTaskHistory(task) {
return await this.save('task', 'history', task, 'system');
}
async saveConfigSnapshot(config) {
return await this.save('config', 'snapshot', config, 'system');
}
listFiles(module, dataType) {
if (!fs.existsSync(this.baseDir)) {
return [];
}
return fs.readdirSync(this.baseDir)
.filter(f => f.startsWith(`${module}_${dataType}_`))
.sort()
.reverse();
}
}
export default new JsonPersistence();

View File

@ -0,0 +1,54 @@
/**
* logger模块 - 日志系统
* 使用winston实现结构化日志
*/
import winston from 'winston';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const logDir = path.resolve(__dirname, '../../logs');
const { combine, timestamp, printf, colorize, errors } = winston.format;
const logFormat = printf(({ level, message, timestamp, stack }) => {
if (stack) {
return `${timestamp} [${level}]: ${message}\n${stack}`;
}
return `${timestamp} [${level}]: ${message}`;
});
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
logFormat
),
transports: [
new winston.transports.Console({
format: combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
logFormat
)
}),
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024,
maxFiles: 5
}),
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
maxsize: 10 * 1024 * 1024,
maxFiles: 5
})
]
});
export default logger;

View File

@ -0,0 +1,164 @@
import Redis from 'ioredis';
import logger from '../logger/index.js';
const SYSTEM_PREFIX = 'comfyui:cluster';
class RedisManager {
constructor() {
this.client = null;
this.connected = false;
this.init();
}
init() {
try {
const host = process.env.REDIS_HOST || 'localhost';
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
const db = parseInt(process.env.REDIS_DB || '0', 10);
this.client = new Redis({
host,
port,
db,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
}
});
this.client.on('connect', () => {
this.connected = true;
logger.info('[Redis Manager] Redis连接成功');
});
this.client.on('error', (err) => {
this.connected = false;
logger.error('[Redis Manager] Redis连接错误:', err);
});
this.client.on('close', () => {
this.connected = false;
logger.warn('[Redis Manager] Redis连接已关闭');
});
} catch (error) {
logger.error('[Redis Manager] Redis初始化失败:', error);
}
}
getKey(module, dataType, id = '') {
if (id) {
return `${SYSTEM_PREFIX}:${module}:${dataType}:${id}`;
}
return `${SYSTEM_PREFIX}:${module}:${dataType}`;
}
async setInstanceStatus(instanceId, status) {
const key = this.getKey('cluster', 'instance', instanceId);
await this.client.hset(key, {
status,
updatedAt: new Date().toISOString()
});
await this.client.expire(key, 86400);
}
async getInstanceStatus(instanceId) {
const key = this.getKey('cluster', 'instance', instanceId);
return await this.client.hgetall(key);
}
async setTask(task) {
const key = this.getKey('task', 'task', task.id);
await this.client.hset(key, {
id: task.id,
promptId: task.promptId || '',
workflow: JSON.stringify(task.workflow),
nodeInfoList: JSON.stringify(task.nodeInfoList || []),
workflowId: task.workflowId || '',
instanceId: task.instanceId,
status: task.status,
progress: task.progress || 0,
createdAt: task.createdAt,
startedAt: task.startedAt || '',
completedAt: task.completedAt || '',
result: JSON.stringify(task.result || null),
error: task.error || ''
});
await this.client.expire(key, 604800);
}
async getTask(taskId) {
const key = this.getKey('task', 'task', taskId);
const data = await this.client.hgetall(key);
if (!data || Object.keys(data).length === 0) return null;
return {
id: data.id,
promptId: data.promptId || null,
workflow: JSON.parse(data.workflow),
nodeInfoList: JSON.parse(data.nodeInfoList),
workflowId: data.workflowId || null,
instanceId: data.instanceId,
status: data.status,
progress: parseInt(data.progress, 10),
createdAt: data.createdAt,
startedAt: data.startedAt || null,
completedAt: data.completedAt || null,
result: JSON.parse(data.result),
error: data.error || null
};
}
async getAllTasks() {
const pattern = this.getKey('task', 'task', '*');
const keys = await this.client.keys(pattern);
const tasks = [];
for (const key of keys) {
const data = await this.client.hgetall(key);
if (data && data.id) {
tasks.push({
id: data.id,
promptId: data.promptId || null,
workflow: JSON.parse(data.workflow),
nodeInfoList: JSON.parse(data.nodeInfoList),
workflowId: data.workflowId || null,
instanceId: data.instanceId,
status: data.status,
progress: parseInt(data.progress, 10),
createdAt: data.createdAt,
startedAt: data.startedAt || null,
completedAt: data.completedAt || null,
result: JSON.parse(data.result),
error: data.error || null
});
}
}
return tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
async deleteTask(taskId) {
const key = this.getKey('task', 'task', taskId);
return await this.client.del(key);
}
async setConfigLock(configKey) {
const key = this.getKey('config', 'lock', configKey);
const result = await this.client.set(key, '1', 'NX', 'EX', 30);
return result === 'OK';
}
async releaseConfigLock(configKey) {
const key = this.getKey('config', 'lock', configKey);
return await this.client.del(key);
}
quit() {
if (this.client) {
this.client.quit();
}
}
}
export default new RedisManager();

View File

@ -0,0 +1,324 @@
import { v4 as uuidv4 } from 'uuid';
import logger from '../logger/index.js';
import clusterManager from '../cluster-manager/index.js';
import webSocketClient from '../websocket-client/index.js';
import axios from 'axios';
import redisManager from '../redis-manager/index.js';
import jsonPersistence from '../json-persistence/index.js';
import taskQueueClient from '../task-queue-client/index.js';
import fileUploader from '../file-uploader/index.js';
import config from '../config/index.js';
import fs from 'fs';
import path from 'path';
class TaskForwarder {
constructor() {
this.setupEventListeners();
}
setupEventListeners() {
webSocketClient.on('execution_start', ({ instanceId, promptId }) => {
this.handleExecutionStart(instanceId, promptId).catch(err => {
logger.error('处理 execution_start 事件失败:', err);
});
});
webSocketClient.on('progress', ({ instanceId, data }) => {
this.handleProgress(instanceId, data).catch(err => {
logger.error('处理 progress 事件失败:', err);
});
});
webSocketClient.on('executed', ({ instanceId, data }) => {
this.handleExecuted(instanceId, data).catch(err => {
logger.error('处理 executed 事件失败:', err);
});
});
webSocketClient.on('execution_error', ({ instanceId, data }) => {
this.handleExecutionError(instanceId, data).catch(err => {
logger.error('处理 execution_error 事件失败:', err);
});
});
}
async submitTask(workflow, nodeInfoList = [], workflowId = null, instanceId = null, webhookUrl = null, queueTaskId = null) {
const taskId = uuidv4();
let instance;
if (instanceId) {
instance = clusterManager.getInstance(instanceId);
if (!instance) {
throw new Error(`实例 ${instanceId} 不存在`);
}
} else {
instance = clusterManager.selectInstance();
if (!instance) {
throw new Error('没有可用的实例');
}
}
const task = {
id: taskId,
promptId: null,
workflow,
nodeInfoList,
workflowId,
webhookUrl,
queueTaskId,
instanceId: instance.id,
status: 'pending',
progress: 0,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
result: null,
error: null
};
await redisManager.setTask(task);
logger.info(`任务已创建: ${taskId}, 分配到实例: ${instance.id}`);
try {
await this.sendTaskToInstance(task, instance);
return taskId;
} catch (error) {
task.status = 'failed';
task.error = error.message;
await redisManager.setTask(task);
logger.error(`任务 ${taskId} 提交失败:`, error);
if (webhookUrl) {
await this.sendWebhookCallback(task, null, error.message);
}
throw error;
}
}
async sendTaskToInstance(task, instance) {
await webSocketClient.connect(instance.id, instance.wsUrl);
const promptMessage = {
prompt: task.workflow,
client_id: task.id
};
webSocketClient.send(instance.id, promptMessage);
task.status = 'submitted';
logger.info(`任务 ${task.id} 已发送到实例 ${instance.id}`);
}
async handleExecutionStart(instanceId, promptId) {
const allTasks = await redisManager.getAllTasks();
for (const task of allTasks) {
if (task.instanceId === instanceId && !task.promptId && task.status === 'submitted') {
task.promptId = promptId;
task.status = 'running';
task.startedAt = new Date().toISOString();
await redisManager.setTask(task);
clusterManager.updateInstanceStatus(instanceId, 'busy');
logger.info(`任务 ${task.id} 开始执行, promptId: ${promptId}`);
break;
}
}
}
async handleProgress(instanceId, data) {
const allTasks = await redisManager.getAllTasks();
for (const task of allTasks) {
if (task.instanceId === instanceId && task.status === 'running') {
if (data.max && data.max > 0) {
task.progress = Math.round((data.value / data.max) * 100);
await redisManager.setTask(task);
}
break;
}
}
}
async handleExecuted(instanceId, data) {
const allTasks = await redisManager.getAllTasks();
for (const task of allTasks) {
if (task.promptId === data.prompt_id && task.status === 'running') {
task.status = 'completed';
task.completedAt = new Date().toISOString();
task.result = data;
await redisManager.setTask(task);
await jsonPersistence.saveTaskHistory(task);
clusterManager.updateInstanceStatus(instanceId, 'online');
logger.info(`任务 ${task.id} 执行完成`);
if (task.webhookUrl) {
await this.sendWebhookCallback(task, data, null);
}
if (task.queueTaskId) {
const resultData = await this.processResultData(data, instanceId);
taskQueueClient.notifyTaskComplete(task.queueTaskId, resultData);
}
break;
}
}
}
async handleExecutionError(instanceId, data) {
const allTasks = await redisManager.getAllTasks();
for (const task of allTasks) {
if (task.promptId === data.prompt_id && task.status === 'running') {
task.status = 'failed';
task.completedAt = new Date().toISOString();
task.error = data.exception_message;
await redisManager.setTask(task);
await jsonPersistence.saveTaskHistory(task);
clusterManager.updateInstanceStatus(instanceId, 'online');
logger.error(`任务 ${task.id} 执行失败: ${data.exception_message}`);
if (task.webhookUrl) {
await this.sendWebhookCallback(task, null, data.exception_message);
}
if (task.queueTaskId) {
taskQueueClient.notifyTaskComplete(task.queueTaskId, null, data.exception_message);
}
break;
}
}
}
async processResultData(data, instanceId) {
const resultData = [];
if (data.output) {
const instance = clusterManager.getInstance(instanceId);
if (!instance) {
return resultData;
}
for (const [nodeId, output] of Object.entries(data.output)) {
if (output.images) {
for (const image of output.images) {
try {
const fileUrl = await this.uploadImage(image, instance);
resultData.push({
fileUrl,
fileType: image.type || 'png',
taskCostTime: 0,
nodeId
});
} catch (error) {
logger.error('上传图片失败:', error);
}
}
}
}
}
return resultData;
}
async uploadImage(image, instance) {
const imageUrl = `${instance.apiUrl}/view?filename=${image.filename}&subfolder=${image.subfolder || ''}&type=${image.type}`;
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer'
});
const uploadsDir = path.resolve(process.cwd(), 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const tempPath = path.join(uploadsDir, `${uuidv4()}.${image.type}`);
await fs.promises.writeFile(tempPath, response.data);
const uploadResult = await fileUploader.uploadToExternalServer(tempPath, image.filename);
await fs.promises.unlink(tempPath);
return uploadResult.url || uploadResult.data?.url;
}
async sendWebhookCallback(task, resultData, error = null) {
if (!task.webhookUrl) {
return;
}
let eventData;
if (error) {
eventData = JSON.stringify({
code: 1,
msg: error,
data: []
});
} else {
const processedData = await this.processResultData(resultData, task.instanceId);
eventData = JSON.stringify({
code: 0,
msg: 'success',
data: processedData
});
}
const callbackMessage = {
event: 'TASK_END',
taskId: task.queueTaskId || task.id,
eventData
};
try {
logger.info(`发送Webhook回调到: ${task.webhookUrl}`);
await axios.post(task.webhookUrl, callbackMessage, {
timeout: 10000
});
logger.info(`Webhook回调发送成功: ${task.id}`);
} catch (error) {
logger.error('发送Webhook回调失败:', error.message);
}
}
async getTask(taskId) {
return await redisManager.getTask(taskId);
}
async getTasks(status = null) {
let tasks = await redisManager.getAllTasks();
if (status) {
tasks = tasks.filter(t => t.status === status);
}
return tasks;
}
async cancelTask(taskId) {
const task = await redisManager.getTask(taskId);
if (!task) {
return false;
}
if (task.status === 'completed' || task.status === 'failed') {
return false;
}
if (task.status === 'running' && task.promptId) {
try {
const instance = clusterManager.getInstance(task.instanceId);
if (instance) {
await axios.post(`${instance.apiUrl}/interrupt`);
}
} catch (error) {
logger.error(`中断任务失败:`, error);
}
}
task.status = 'cancelled';
task.completedAt = new Date().toISOString();
await redisManager.setTask(task);
await jsonPersistence.saveTaskHistory(task);
logger.info(`任务 ${taskId} 已取消`);
return true;
}
}
export default new TaskForwarder();

View File

@ -0,0 +1,361 @@
import WebSocket from 'ws';
import logger from '../logger/index.js';
import config from '../config/index.js';
import clusterManager from '../cluster-manager/index.js';
import taskForwarder from '../task-forwarder/index.js';
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
import EventEmitter from 'events';
class MessageDispatcherClient extends EventEmitter {
constructor() {
super();
this.ws = null;
this.reconnectTimer = null;
this.isConnected = false;
this.pendingTasks = new Map();
this.bridgeId = config.get('messageDispatcher.bridgeId', 'bridge-1');
}
start() {
this.connect();
config.on?.('change', () => {
this.handleConfigChange();
});
this.startHeartbeatInterval();
}
startHeartbeatInterval() {
setInterval(() => {
this.sendHeartbeat();
}, 30000);
}
getWebSocketUrl() {
return config.get('messageDispatcher.websocketUrl', 'ws://localhost:4000/ws');
}
connect() {
const wsUrl = this.getWebSocketUrl();
if (this.ws) {
this.ws.removeAllListeners();
this.ws.close();
}
logger.info(`[MessageDispatcher] 正在连接到统一消息分发后端: ${wsUrl}`);
try {
this.ws = new WebSocket(wsUrl);
} catch (error) {
logger.error('[MessageDispatcher] 创建WebSocket连接失败:', error);
this.scheduleReconnect();
return;
}
this.ws.on('open', () => {
logger.info('[MessageDispatcher] 已连接到统一消息分发后端');
this.isConnected = true;
this.sendRegisterMessage();
});
this.ws.on('message', (data) => {
this.handleMessage(data).catch(err => {
logger.error('[MessageDispatcher] 处理消息失败:', err);
});
});
this.ws.on('error', (error) => {
logger.error('[MessageDispatcher] WebSocket连接错误:', error);
this.isConnected = false;
});
this.ws.on('close', (code, reason) => {
logger.warn(`[MessageDispatcher] 与统一消息分发后端的连接已关闭 (code: ${code})`);
this.isConnected = false;
this.scheduleReconnect();
});
}
scheduleReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
logger.info('[MessageDispatcher] 60秒后尝试重新连接...');
this.reconnectTimer = setTimeout(() => {
this.connect();
}, 60000);
}
handleConfigChange() {
const currentUrl = this.getWebSocketUrl();
if (this.ws && this.ws.url !== currentUrl) {
logger.info('[MessageDispatcher] 配置已变更,重新连接...');
this.connect();
}
}
sendRegisterMessage() {
const instances = clusterManager.getAllInstances();
const onlineCount = instances.filter(i => i.status === 'online').length;
const totalCount = instances.length;
const registerMessage = {
type: 'REGISTER',
data: {
bridgeId: this.bridgeId,
instanceCount: totalCount,
availableInstanceCount: onlineCount,
instances: instances.map(i => ({
id: i.id,
serverId: i.serverId,
serverName: i.serverName,
ip: i.ip,
port: i.port,
status: i.status,
load: i.load,
currentTasks: i.currentTasks
})),
timestamp: new Date().toISOString()
}
};
this.send(registerMessage);
}
sendHeartbeat() {
if (!this.isConnected) return;
const instances = clusterManager.getAllInstances();
const onlineCount = instances.filter(i => i.status === 'online').length;
const busyCount = instances.filter(i => i.status === 'busy').length;
const heartbeatMessage = {
type: 'HEARTBEAT',
data: {
bridgeId: this.bridgeId,
instanceCount: instances.length,
availableInstanceCount: onlineCount,
busyInstanceCount: busyCount,
instances: instances.map(i => ({
id: i.id,
status: i.status,
load: i.load,
currentTasks: i.currentTasks
})),
timestamp: new Date().toISOString()
}
};
this.send(heartbeatMessage);
}
send(message) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
logger.warn('[MessageDispatcher] WebSocket未连接无法发送消息');
return;
}
this.ws.send(JSON.stringify(message));
}
async handleMessage(data) {
try {
const message = JSON.parse(data.toString());
logger.debug('[MessageDispatcher] 收到消息:', message.type);
switch (message.type) {
case 'REGISTER_ACK':
logger.info('[MessageDispatcher] 注册成功');
break;
case 'TASK_ASSIGN':
await this.handleTaskAssign(message.data);
break;
case 'INSTANCE_CHECK':
await this.handleInstanceCheck(message.data);
break;
case 'PING':
this.send({ type: 'PONG' });
break;
default:
logger.debug('[MessageDispatcher] 未知消息类型:', message.type);
}
} catch (error) {
logger.error('[MessageDispatcher] 解析消息失败:', error);
}
}
async handleInstanceCheck(checkData) {
const { checkType, instanceId, requestId } = checkData;
logger.info(`[MessageDispatcher] 收到实例检查请求: ${checkType}, instanceId: ${instanceId}`);
try {
let result;
switch (checkType) {
case 'single':
result = await clusterManager.checkInstanceHealthNow(instanceId);
break;
case 'offline':
result = await clusterManager.checkOfflineInstancesHealth();
break;
case 'all':
result = await clusterManager.checkAllInstancesHealth();
break;
default:
throw new Error('未知的检查类型');
}
const ackResponse = {
type: 'INSTANCE_CHECK_ACK',
data: {
requestId,
code: 0,
msg: 'success',
data: result
}
};
this.send(ackResponse);
this.sendRegisterMessage();
} catch (error) {
logger.error('[MessageDispatcher] 处理实例检查失败:', error);
const ackResponse = {
type: 'INSTANCE_CHECK_ACK',
data: {
requestId,
code: 1,
msg: error.message
}
};
this.send(ackResponse);
}
}
async handleTaskAssign(taskData) {
const { workflowId, nodeInfoList, webhookUrl, requestId } = taskData;
const taskId = uuidv4();
logger.info(`[MessageDispatcher] 收到任务: ${workflowId}, 生成taskId: ${taskId}`);
const ackResponse = {
type: 'TASK_ACK',
data: {
requestId,
code: 0,
msg: 'success',
data: {
taskId,
taskStatus: 'RUNNING'
}
}
};
this.send(ackResponse);
const taskRecord = {
id: taskId,
workflowId,
nodeInfoList,
webhookUrl,
requestId,
status: 'pending',
createdAt: new Date().toISOString()
};
this.pendingTasks.set(taskId, taskRecord);
try {
const actualTaskId = await taskForwarder.submitTask(
{},
nodeInfoList,
workflowId
);
taskRecord.status = 'running';
taskRecord.actualTaskId = actualTaskId;
this.pendingTasks.set(taskId, taskRecord);
logger.info(`[MessageDispatcher] 任务已提交: ${actualTaskId}`);
} catch (error) {
logger.error('[MessageDispatcher] 提交任务失败:', error);
taskRecord.status = 'failed';
taskRecord.error = error.message;
this.pendingTasks.set(taskId, taskRecord);
await this.sendTaskEndCallback(taskId, null, error.message);
}
}
async sendTaskEndCallback(taskId, resultData, error = null) {
const taskRecord = this.pendingTasks.get(taskId);
if (!taskRecord) {
logger.warn(`[MessageDispatcher] 任务记录不存在: ${taskId}`);
return;
}
const { webhookUrl, requestId } = taskRecord;
if (!webhookUrl) {
logger.warn('[MessageDispatcher] 缺少webhookUrl无法发送回调');
} else {
let eventData;
if (error) {
eventData = JSON.stringify({
code: 1,
msg: error,
data: []
});
} else {
eventData = JSON.stringify({
code: 0,
msg: 'success',
data: resultData || []
});
}
const callbackMessage = {
event: 'TASK_END',
taskId,
eventData
};
try {
logger.info(`[MessageDispatcher] 发送回调到: ${webhookUrl}`);
await axios.post(webhookUrl, callbackMessage, {
timeout: 10000
});
logger.info(`[MessageDispatcher] 回调发送成功: ${taskId}`);
} catch (error) {
logger.error('[MessageDispatcher] 发送回调失败:', error.message);
}
}
const taskEndMessage = {
type: 'TASK_END',
data: {
requestId,
taskId,
result: resultData,
error: error
}
};
this.send(taskEndMessage);
this.pendingTasks.delete(taskId);
}
notifyTaskComplete(taskId, result) {
this.sendTaskEndCallback(taskId, result).catch(err => {
logger.error('[MessageDispatcher] 处理任务完成通知失败:', err);
});
}
stop() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
if (this.ws) {
this.ws.close();
}
}
}
export default new MessageDispatcherClient();

View File

@ -0,0 +1,171 @@
/**
* websocket-client模块 - 与ComfyUI实例的WebSocket通信
*/
import WebSocket from 'ws';
import logger from '../logger/index.js';
import EventEmitter from 'events';
import comfyUIMonitor from '../comfyui-monitor/index.js';
class WebSocketClient extends EventEmitter {
constructor() {
super();
this.connections = new Map();
}
/**
* 连接到指定实例
* @param {string} instanceId - 实例ID
* @param {string} wsUrl - WebSocket地址
* @returns {Promise<WebSocket>} WebSocket连接
*/
connect(instanceId, wsUrl) {
return new Promise((resolve, reject) => {
if (this.connections.has(instanceId)) {
const conn = this.connections.get(instanceId);
if (conn.readyState === WebSocket.OPEN) {
resolve(conn);
return;
}
}
logger.info(`正在连接到实例 ${instanceId}: ${wsUrl}`);
const ws = new WebSocket(wsUrl);
ws.on('open', () => {
logger.info(`成功连接到实例 ${instanceId}`);
this.connections.set(instanceId, ws);
const stateChange = comfyUIMonitor.setInstanceState(instanceId, 'connected');
if (stateChange) {
const config = { wsUrl };
comfyUIMonitor.logConnectionStateChange(
instanceId,
stateChange.oldState,
'connected',
'WebSocket连接成功',
config
);
}
resolve(ws);
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(instanceId, message);
} catch (error) {
logger.error(`解析消息失败 (${instanceId}):`, error);
}
});
ws.on('error', (error) => {
logger.error(`WebSocket连接错误 (${instanceId}):`, error);
const config = { wsUrl };
comfyUIMonitor.logConnectionError(instanceId, error, config);
reject(error);
});
ws.on('close', (code, reason) => {
logger.warn(`与实例 ${instanceId} 的连接已关闭`);
const stateChange = comfyUIMonitor.setInstanceState(instanceId, 'disconnected');
if (stateChange) {
const disconnectReason = reason ? reason.toString() : `关闭代码: ${code}`;
const config = { wsUrl, closeCode: code };
comfyUIMonitor.logConnectionStateChange(
instanceId,
stateChange.oldState,
'disconnected',
disconnectReason,
config
);
}
this.connections.delete(instanceId);
this.emit('disconnected', { instanceId });
});
});
}
/**
* 处理收到的消息
* @param {string} instanceId - 实例ID
* @param {object} message - 消息对象
*/
handleMessage(instanceId, message) {
this.emit('message', { instanceId, message });
switch (message.type) {
case 'status':
this.emit('status', { instanceId, status: message.data });
break;
case 'progress':
this.emit('progress', { instanceId, data: message.data });
break;
case 'execution_start':
this.emit('execution_start', { instanceId, promptId: message.data.prompt_id });
break;
case 'execution_cached':
this.emit('execution_cached', { instanceId, data: message.data });
break;
case 'executed':
this.emit('executed', { instanceId, data: message.data });
break;
case 'execution_error':
this.emit('execution_error', { instanceId, data: message.data });
break;
}
}
/**
* 发送消息到指定实例
* @param {string} instanceId - 实例ID
* @param {object} message - 消息对象
*/
send(instanceId, message) {
const ws = this.connections.get(instanceId);
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error(`实例 ${instanceId} 未连接`);
}
ws.send(JSON.stringify(message));
}
/**
* 断开指定实例的连接
* @param {string} instanceId - 实例ID
*/
disconnect(instanceId) {
const ws = this.connections.get(instanceId);
if (ws) {
ws.close();
this.connections.delete(instanceId);
}
}
/**
* 断开所有连接
*/
disconnectAll() {
for (const [instanceId, ws] of this.connections) {
ws.close();
}
this.connections.clear();
}
/**
* 检查实例是否已连接
* @param {string} instanceId - 实例ID
* @returns {boolean} 连接状态
*/
isConnected(instanceId) {
const ws = this.connections.get(instanceId);
return ws && ws.readyState === WebSocket.OPEN;
}
}
export default new WebSocketClient();

3
frontend/.env Normal file
View File

@ -0,0 +1,3 @@
# 默认环境配置
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api
VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000

View File

@ -0,0 +1,3 @@
# 默认环境配置
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api
VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000

2
frontend/.env.production Normal file
View File

@ -0,0 +1,2 @@
# 生产环境配置
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ComfyUI Cluster Bridge - 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "comfyui-cluster-bridge-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.11",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.4.4",
"axios": "^1.6.2",
"echarts": "^5.4.3",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.8",
"sass": "^1.69.5"
}
}

1404
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

17
frontend/src/App.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
#app {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
width: 100vw;
overflow: hidden;
}
</style>

233
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,233 @@
import request from '@/utils/request'
import axios from 'axios'
const messageDispatcherRequest = axios.create({
baseURL: '/api',
timeout: 10000
})
messageDispatcherRequest.interceptors.request.use(
(config) => {
const accessToken = sessionStorage.getItem('accessToken')
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// ==================== 认证相关 API (通过 message-dispatcher) ====================
export async function login(username, password) {
const res = await messageDispatcherRequest.post('/auth/login', { username, password })
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '登录失败')
}
export async function refreshToken(refreshToken) {
const res = await messageDispatcherRequest.post('/auth/refresh', { refreshToken })
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '刷新令牌失败')
}
export async function logout(refreshToken) {
const res = await messageDispatcherRequest.post('/auth/logout', { refreshToken })
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '登出失败')
}
export async function getMe() {
const res = await messageDispatcherRequest.get('/auth/me')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '获取用户信息失败')
}
// ==================== 实例相关 API (通过 message-dispatcher) ====================
export async function getInstances() {
const res = await messageDispatcherRequest.get('/instances')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '获取实例列表失败')
}
export async function getInstance(instanceId) {
const res = await messageDispatcherRequest.get(`/instances/${instanceId}`)
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '获取实例详情失败')
}
export async function checkInstanceHealth(instanceId) {
const res = await messageDispatcherRequest.post(`/instances/${instanceId}/health-check`)
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '检查实例健康状态失败')
}
export async function checkOfflineInstancesHealth() {
const res = await messageDispatcherRequest.post('/instances/health-check-offline')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '检查离线实例失败')
}
export async function checkAllInstancesHealth() {
const res = await messageDispatcherRequest.post('/instances/health-check-all')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '检查全部实例失败')
}
// ==================== 桥接器相关 API ====================
export async function getMessageDispatcherHealth() {
const res = await messageDispatcherRequest.get('/health')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '请求失败')
}
export async function getBridges() {
const res = await messageDispatcherRequest.get('/bridges')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '请求失败')
}
export async function getBridge(bridgeId) {
const res = await messageDispatcherRequest.get(`/bridges/${bridgeId}`)
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '请求失败')
}
export async function getDispatcherOverview() {
const res = await messageDispatcherRequest.get('/overview')
if (res.data.success) {
return res.data.data
}
throw new Error(res.data.message || '请求失败')
}
// ==================== 配置相关 API (保持旧接口兼容) ====================
export function getConfig() {
return request({
url: '/config',
method: 'get'
})
}
export function updateConfig(data) {
return request({
url: '/config',
method: 'put',
data
})
}
export function getMessageDispatcherConfig() {
return request({
url: '/config/message-dispatcher',
method: 'get'
})
}
export function updateMessageDispatcherConfig(data) {
return request({
url: '/config/message-dispatcher',
method: 'put',
data
})
}
export function reconnectMessageDispatcher() {
return request({
url: '/message-dispatcher/reconnect',
method: 'post'
})
}
// ==================== 监控相关 API (保持旧接口兼容) ====================
export function getMonitorOverview() {
return request({
url: '/monitor/overview',
method: 'get'
})
}
export function healthCheck() {
return request({
url: '/health',
method: 'get'
})
}
// ==================== 任务相关 API (保持旧接口兼容) ====================
export function getTasks(status) {
return request({
url: '/tasks',
method: 'get',
params: { status }
})
}
export function getTask(taskId) {
return request({
url: `/tasks/${taskId}`,
method: 'get'
})
}
export function submitTask(data) {
return request({
url: '/tasks',
method: 'post',
data
})
}
export function cancelTask(taskId) {
return request({
url: `/tasks/${taskId}`,
method: 'delete'
})
}
// 保持旧接口兼容 - 这些功能现已由 message-dispatcher 处理
export function addInstance(data) {
console.warn('addInstance API 已废弃,实例配置应在桥接器端管理')
return Promise.reject(new Error('API 已废弃'))
}
export function updateInstance(instanceId, data) {
console.warn('updateInstance API 已废弃,实例配置应在桥接器端管理')
return Promise.reject(new Error('API 已废弃'))
}
export function deleteInstance(instanceId) {
console.warn('deleteInstance API 已废弃,实例配置应在桥接器端管理')
return Promise.reject(new Error('API 已废弃'))
}

View File

@ -0,0 +1,278 @@
<template>
<el-container class="main-layout">
<el-aside :width="isCollapse ? '64px' : '240px'" class="sidebar">
<div class="logo">
<div class="logo-icon">CB</div>
<h2 v-if="!isCollapse">Cluster Bridge</h2>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse-transition="false"
router
class="custom-menu"
>
<el-menu-item index="/monitor">
<el-icon><Monitor /></el-icon>
<template #title>系统监控</template>
</el-menu-item>
<el-menu-item index="/instances">
<el-icon><Monitor /></el-icon>
<template #title>服务器与实例</template>
</el-menu-item>
<el-menu-item index="/tasks">
<el-icon><List /></el-icon>
<template #title>任务管理</template>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>配置管理</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header class="header">
<div class="header-left">
<div class="collapse-btn" @click="toggleCollapse">
<el-icon :size="20">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
</div>
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<div class="user-info">
<div class="user-avatar">
<el-icon><User /></el-icon>
</div>
<span v-if="!isCollapse" class="username">{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Fold, Expand, Monitor, List, Setting, User, SwitchButton } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
const activeMenu = computed(() => route.path)
const pageTitle = computed(() => {
return route.meta?.title || '系统监控'
})
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
const handleCommand = async (command) => {
if (command === 'logout') {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
userStore.logout()
ElMessage.success('已退出登录')
router.push('/login')
} catch {
}
}
}
</script>
<style scoped lang="scss">
@import '@/styles/design-system.scss';
.main-layout {
height: 100%;
width: 100%;
}
.sidebar {
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
transition: width $transition-base;
overflow: hidden;
display: flex;
flex-direction: column;
.logo {
height: 72px;
display: flex;
align-items: center;
gap: $space-12;
padding: 0 $space-20;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.logo-icon {
width: 40px;
height: 40px;
background: $primary-gradient;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
color: $text-white;
font-weight: $font-weight-bold;
font-size: $font-size-sm;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
h2 {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
margin: 0;
color: $text-white;
white-space: nowrap;
}
}
:deep(.custom-menu) {
border-right: none;
background: transparent;
flex: 1;
padding: $space-12 0;
.el-menu-item {
margin: $space-4 $space-12;
border-radius: $radius-md;
color: rgba(255, 255, 255, 0.7);
transition: all $transition-base;
height: 48px;
line-height: 48px;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: $text-white;
}
&.is-active {
background: $primary-gradient;
color: $text-white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.el-icon {
font-size: 20px;
}
}
}
}
.main-container {
display: flex;
flex-direction: column;
}
.header {
@include glassmorphism;
border-bottom: 1px solid $border-light;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 $space-24;
height: 72px;
z-index: 10;
.header-left {
display: flex;
align-items: center;
gap: $space-16;
.collapse-btn {
width: 40px;
height: 40px;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: $text-secondary;
transition: all $transition-fast;
&:hover {
background: $bg-surface;
color: $primary-start;
}
}
.page-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-primary;
letter-spacing: 0.3px;
}
}
.header-right {
.user-info {
display: flex;
align-items: center;
gap: $space-12;
cursor: pointer;
padding: $space-8 $space-12;
border-radius: $radius-lg;
transition: all $transition-fast;
&:hover {
background: $bg-surface;
}
.user-avatar {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
color: $text-white;
.el-icon {
font-size: 20px;
}
}
.username {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-secondary;
}
}
}
}
.main-content {
padding: 0;
background-color: $bg-page;
overflow: hidden;
}
</style>

22
frontend/src/main.js Normal file
View File

@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import './styles/index.scss'
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,62 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
redirect: '/monitor',
children: [
{
path: 'instances',
name: 'Instances',
component: () => import('@/views/Instances.vue'),
meta: { title: '服务器与实例' }
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('@/views/Tasks.vue'),
meta: { title: '任务管理' }
},
{
path: 'config',
name: 'Config',
component: () => import('@/views/Config.vue'),
meta: { title: '配置管理' }
},
{
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/Monitor.vue'),
meta: { title: '系统监控' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
next('/login')
} else if (to.path === '/login' && userStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getConfig, updateConfig as updateConfigApi } from '@/api'
export const useConfigStore = defineStore('config', () => {
const config = ref({})
const loading = ref(false)
async function fetchConfig() {
loading.value = true
try {
config.value = await getConfig()
} catch (error) {
console.error('获取配置失败:', error)
} finally {
loading.value = false
}
}
async function updateConfig(updates) {
try {
config.value = await updateConfigApi(updates)
} catch (error) {
console.error('更新配置失败:', error)
throw error
}
}
return {
config,
loading,
fetchConfig,
updateConfig
}
})

View File

@ -0,0 +1,73 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getInstances, checkInstanceHealth, checkOfflineInstancesHealth, checkAllInstancesHealth } from '@/api'
export const useInstanceStore = defineStore('instance', () => {
const instances = ref([])
const loading = ref(false)
async function fetchInstances() {
loading.value = true
try {
instances.value = await getInstances()
} catch (error) {
console.error('获取实例列表失败:', error)
} finally {
loading.value = false
}
}
// 按服务器分组实例
function getInstancesByServer() {
const groups = {}
instances.value.forEach(instance => {
const serverName = instance.serverName || instance.host || '未知服务器'
if (!groups[serverName]) {
groups[serverName] = []
}
groups[serverName].push(instance)
})
return groups
}
async function checkInstance(instanceId) {
try {
const instance = await checkInstanceHealth(instanceId)
await fetchInstances()
return instance
} catch (error) {
console.error('检查实例健康状态失败:', error)
throw error
}
}
async function checkOfflineInstances() {
try {
await checkOfflineInstancesHealth()
await fetchInstances()
} catch (error) {
console.error('检查离线实例失败:', error)
throw error
}
}
async function checkAllInstances() {
try {
await checkAllInstancesHealth()
await fetchInstances()
} catch (error) {
console.error('检查所有实例失败:', error)
throw error
}
}
return {
instances,
loading,
fetchInstances,
getInstancesByServer,
checkInstance,
checkOfflineInstances,
checkAllInstances
}
})

View File

@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getTasks, cancelTask as cancelTaskApi } from '@/api'
export const useTaskStore = defineStore('task', () => {
const tasks = ref([])
const loading = ref(false)
async function fetchTasks(status) {
loading.value = true
try {
tasks.value = await getTasks(status)
} catch (error) {
console.error('获取任务列表失败:', error)
} finally {
loading.value = false
}
}
async function cancelTask(taskId) {
try {
await cancelTaskApi(taskId)
await fetchTasks()
} catch (error) {
console.error('取消任务失败:', error)
throw error
}
}
return {
tasks,
loading,
fetchTasks,
cancelTask
}
})

View File

@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi, refreshToken as refreshTokenApi, logout as logoutApi, getMe as getMeApi } from '@/api'
export const useUserStore = defineStore('user', () => {
const accessToken = ref(sessionStorage.getItem('accessToken') || '')
const refreshToken = ref(sessionStorage.getItem('refreshToken') || '')
const username = ref(sessionStorage.getItem('username') || '')
const userInfo = ref(null)
const isAuthenticated = computed(() => !!accessToken.value)
function setTokens(access, refresh) {
accessToken.value = access
refreshToken.value = refresh
sessionStorage.setItem('accessToken', access)
sessionStorage.setItem('refreshToken', refresh)
}
function setUsername(name) {
username.value = name
sessionStorage.setItem('username', name)
}
async function login(username, password) {
const data = await loginApi(username, password)
// 假设响应 data 包含 { accessToken, refreshToken, user }
setTokens(data.accessToken, data.refreshToken)
if (data.user) {
userInfo.value = data.user
setUsername(data.user.username || username)
} else {
setUsername(username)
}
return data
}
async function refreshTokens() {
if (!refreshToken.value) {
throw new Error('No refresh token available')
}
const data = await refreshTokenApi(refreshToken.value)
setTokens(data.accessToken, data.refreshToken)
return data.accessToken
}
async function getMe() {
const data = await getMeApi()
userInfo.value = data
return data
}
async function logout() {
try {
if (refreshToken.value) {
await logoutApi(refreshToken.value)
}
} catch (error) {
console.error('Logout API error:', error)
}
accessToken.value = ''
refreshToken.value = ''
username.value = ''
userInfo.value = null
sessionStorage.removeItem('accessToken')
sessionStorage.removeItem('refreshToken')
sessionStorage.removeItem('username')
}
return {
accessToken,
refreshToken,
username,
userInfo,
isAuthenticated,
login,
logout,
refreshToken: refreshTokens,
getMe
}
})

View File

@ -0,0 +1,247 @@
// ========================================
// Design System - 全局设计系统
// ========================================
// ----------------------------------------
// 色彩系统 (Color System)
// ----------------------------------------
// 主色调 - 紫蓝渐变
$primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
$primary-start: #6366f1;
$primary-end: #8b5cf6;
$primary-hover: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
// 背景色 - 分层背景
$bg-page: #f8fafc;
$bg-card: #ffffff;
$bg-surface: #f1f5f9;
$bg-hover: #e2e8f0;
// 文字色 - 三级文字体系
$text-primary: #1e293b;
$text-secondary: #475569;
$text-tertiary: #94a3b8;
$text-white: #ffffff;
// 状态色
$status-success: #10b981;
$status-warning: #f59e0b;
$status-error: #ef4444;
$status-info: #3b82f6;
// 边框色
$border-light: #e2e8f0;
$border-medium: #cbd5e1;
// ----------------------------------------
// 栅格系统 (Grid System)
// ----------------------------------------
// 12列栅格系统
$grid-columns: 12;
$grid-gutter: 24px;
$container-max-width: 1200px;
// 断点
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
// ----------------------------------------
// 间距系统 (Spacing System)
// ----------------------------------------
// 4px/8px 网格原则 - 8px的整数倍
$space-4: 4px;
$space-8: 8px;
$space-12: 12px;
$space-16: 16px;
$space-20: 20px;
$space-24: 24px;
$space-32: 32px;
$space-40: 40px;
$space-48: 48px;
$space-64: 64px;
// ----------------------------------------
// 圆角系统 (Border Radius)
// ----------------------------------------
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 12px;
$radius-xl: 16px;
$radius-full: 9999px;
// ----------------------------------------
// 阴影系统 (Shadow System)
// ----------------------------------------
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 20px -2px rgba(0, 0, 0, 0.08);
$shadow-lg: 0 12px 40px -4px rgba(0, 0, 0, 0.12);
$shadow-primary: 0 8px 24px -4px rgba(99, 102, 241, 0.25);
$shadow-hover: 0 12px 40px -4px rgba(99, 102, 241, 0.2);
// ----------------------------------------
// 过渡动画 (Transitions)
// ----------------------------------------
$transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
$transition-base: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
$transition-slow: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
// ----------------------------------------
// 字体系统 (Typography)
// ----------------------------------------
$font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
$font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
$font-size-xs: 12px;
$font-size-sm: 14px;
$font-size-base: 16px;
$font-size-lg: 18px;
$font-size-xl: 20px;
$font-size-2xl: 24px;
$font-size-3xl: 32px;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// ----------------------------------------
// 通用混合器 (Mixins)
// ----------------------------------------
// 响应式容器
@mixin container {
width: 100%;
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $space-24;
}
// 毛玻璃效果
@mixin glassmorphism {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
// 按钮基础样式
@mixin button-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-8;
border-radius: $radius-lg;
font-weight: $font-weight-medium;
font-size: $font-size-sm;
transition: all $transition-base;
cursor: pointer;
border: none;
outline: none;
user-select: none;
}
// 主按钮
@mixin button-primary {
@include button-base;
background: $primary-gradient;
color: $text-white;
box-shadow: $shadow-primary;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-hover;
}
&:active {
transform: scale(0.98);
}
}
// 次要按钮幽灵按钮
@mixin button-ghost {
@include button-base;
background: transparent;
color: $primary-start;
border: 1px solid $primary-start;
&:hover {
background: rgba(99, 102, 241, 0.08);
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
}
// 卡片样式
@mixin card-base {
background: $bg-card;
border-radius: $radius-xl;
box-shadow: $shadow-md;
border: 1px solid $border-light;
transition: all $transition-base;
}
// 输入框基础样式
@mixin input-base {
width: 100%;
padding: $space-12 $space-16;
border: 1px solid $border-light;
border-radius: $radius-md;
background: $bg-card;
font-size: $font-size-sm;
color: $text-primary;
transition: all $transition-base;
outline: none;
&:hover {
border-color: $border-medium;
}
&:focus {
border-color: transparent;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2), 0 0 0 1px $primary-start;
}
&::placeholder {
color: $text-tertiary;
}
}
// 微交互 - Hover效果
@mixin hover-lift {
transition: all $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
}
// 微交互 - Click效果
@mixin click-scale {
transition: transform $transition-fast;
&:active {
transform: scale(0.98);
}
}
// 渐变色文字
@mixin gradient-text {
background: $primary-gradient;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

View File

@ -0,0 +1,168 @@
@import './design-system.scss';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
width: 100%;
}
body {
background-color: $bg-page;
font-family: $font-family;
color: $text-primary;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.status-online {
color: $status-success;
}
.status-busy {
color: $status-warning;
}
.status-offline {
color: $status-error;
}
.status-pending {
color: $text-tertiary;
}
.status-running {
color: $status-info;
}
.status-completed {
color: $status-success;
}
.status-failed {
color: $status-error;
}
.page-container {
padding: $space-24;
height: 100%;
overflow-y: auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.page-content {
@include container;
padding-top: $space-24;
padding-bottom: $space-24;
}
.el-button {
border-radius: $radius-lg !important;
font-weight: $font-weight-medium !important;
transition: all $transition-base !important;
&:hover {
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
}
.el-button--primary {
background: $primary-gradient !important;
border: none !important;
box-shadow: $shadow-primary;
&:hover {
background: $primary-hover !important;
box-shadow: $shadow-hover;
}
}
.el-input__wrapper {
border-radius: $radius-md !important;
box-shadow: 0 0 0 1px $border-light inset !important;
&:hover {
box-shadow: 0 0 0 1px $border-medium inset !important;
}
&.is-focus {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2), 0 0 0 1px $primary-start inset !important;
}
}
.el-card {
border-radius: $radius-xl !important;
box-shadow: $shadow-md !important;
border: 1px solid $border-light !important;
&:hover {
box-shadow: $shadow-lg !important;
}
.el-card__header {
border-bottom: 1px solid $border-light;
}
}
.el-table {
border-radius: $radius-md;
overflow: hidden;
th.el-table__cell {
background: $bg-surface !important;
color: $text-secondary !important;
font-weight: $font-weight-semibold !important;
}
tr.el-table__row:hover {
td.el-table__cell {
background: rgba(99, 102, 241, 0.04) !important;
}
}
}
.el-dialog {
border-radius: $radius-xl !important;
.el-dialog__header {
padding: $space-20 $space-24 $space-16;
border-bottom: 1px solid $border-light;
}
.el-dialog__body {
padding: $space-24;
}
.el-dialog__footer {
padding: $space-16 $space-24 $space-20;
border-top: 1px solid $border-light;
}
}
.el-tag {
border-radius: $radius-sm !important;
font-weight: $font-weight-medium;
}
.el-select .el-input__wrapper {
box-shadow: 0 0 0 1px $border-light inset !important;
}
.el-select .el-input.is-focus .el-input__wrapper {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2), 0 0 0 1px $primary-start inset !important;
}

View File

@ -0,0 +1,91 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 30000
})
let isRefreshing = false
let refreshSubscribers = []
function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback)
}
function onTokenRefreshed(newAccessToken) {
refreshSubscribers.forEach(callback => callback(newAccessToken))
refreshSubscribers = []
}
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.accessToken) {
config.headers.Authorization = `Bearer ${userStore.accessToken}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.success) {
return res.data
} else {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
},
async (error) => {
const originalRequest = error.config
const userStore = useUserStore()
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve) => {
subscribeTokenRefresh((newAccessToken) => {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
if (userStore.refreshToken) {
const newAccessToken = await userStore.refreshToken()
onTokenRefreshed(newAccessToken)
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
return request(originalRequest)
} else {
userStore.logout()
router.push('/login')
return Promise.reject(error)
}
} catch (refreshError) {
userStore.logout()
router.push('/login')
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
const errorMessage = error.response?.data?.message || error.message || '网络错误'
ElMessage.error(errorMessage)
return Promise.reject(error)
}
)
export default request

View File

@ -0,0 +1,339 @@
<template>
<div class="page-container">
<el-card class="card-header">
<template #header>
<div class="card-header-content">
<span>ComfyUI 实例管理</span>
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
添加实例
</el-button>
</div>
</template>
<el-empty v-if="!loading && instances.length === 0" description="暂无实例,点击上方按钮添加" />
<el-skeleton v-else-if="loading" :rows="5" animated />
<template v-else>
<el-table :data="instances" style="width: 100%" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="ip" label="IP 地址" width="150" />
<el-table-column prop="port" label="端口" width="100" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getTagType(row.enabled)" size="small">
{{ getTagText(row.enabled) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" link @click="openEditDialog(row)">
编辑
</el-button>
<el-popconfirm title="确定要删除这个实例吗?" @confirm="handleDelete(row.id)">
<el-button type="danger" size="small" link>
删除
</el-button>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</template>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑实例' : '添加实例'"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="服务器" prop="serverId">
<el-select v-model="form.serverId" placeholder="选择服务器">
<el-option
v-for="server in servers"
:key="server.id"
:label="server.name"
:value="server.id"
/>
</el-select>
</el-form-item>
<el-form-item label="实例名称" prop="name">
<el-input v-model="form.name" placeholder="例如:实例-1" />
</el-form-item>
<el-form-item label="端口号" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getInstances, addInstance, updateInstance, deleteInstance } from '@/api'
const loading = ref(false)
const instances = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const currentId = ref(null)
const formRef = ref(null)
const servers = ref([
{ id: 'server-1', name: '主服务器', ip: '127.0.0.1' }
])
const form = ref({
serverId: 'server-1',
name: '',
port: 8001,
enabled: true
})
const rules = {
serverId: [
{ required: true, message: '请选择服务器', trigger: 'change' }
],
name: [
{ required: true, message: '请输入实例名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号范围 1-65535', trigger: 'blur' }
]
}
const getTagType = (enabled) => {
return enabled ? 'success' : 'info'
}
const getTagText = (enabled) => {
return enabled ? '启用' : '禁用'
}
const fetchInstances = async () => {
loading.value = true
try {
const data = await getInstances()
instances.value = data || []
} catch (error) {
console.error('获取实例列表失败:', error)
} finally {
loading.value = false
}
}
const openAddDialog = () => {
isEdit.value = false
currentId.value = null
form.value = {
serverId: 'server-1',
name: '',
port: 8001 + instances.value.length,
enabled: true
}
dialogVisible.value = true
}
const openEditDialog = (row) => {
isEdit.value = true
currentId.value = row.id
form.value = {
serverId: row.serverId,
name: row.name,
port: row.port,
enabled: row.enabled
}
dialogVisible.value = true
}
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
saving.value = true
if (isEdit.value) {
await updateInstance(currentId.value, form.value)
ElMessage.success('实例更新成功')
} else {
await addInstance(form.value)
ElMessage.success('实例添加成功')
}
dialogVisible.value = false
await fetchInstances()
} catch (error) {
if (error !== false) {
ElMessage.error(isEdit.value ? '更新失败' : '添加失败')
}
} finally {
saving.value = false
}
}
const handleDelete = async (id) => {
try {
await deleteInstance(id)
ElMessage.success('删除成功')
await fetchInstances()
} catch (error) {
ElMessage.error('删除失败')
}
}
onMounted(() => {
fetchInstances()
})
</script>
<style scoped lang="scss">
//
$primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
$page-bg: #f8fafc;
$card-bg: #ffffff;
$text-primary: #1e293b;
$text-secondary: #475569;
$text-muted: #94a3b8;
$radius-btn: 12px;
$radius-card: 16px;
$radius-input: 10px;
$shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.08);
.page-container {
height: 100%;
overflow-y: auto;
padding: 24px;
background: $page-bg;
}
.card-header {
border-radius: $radius-card;
box-shadow: $shadow;
border: none;
background: $card-bg;
:deep(.el-card__header) {
background: $primary-gradient;
border-bottom: none;
padding: 20px 24px;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
> span {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
:deep(.el-card__body) {
padding: 24px;
background: $card-bg;
}
}
//
:deep(.el-button) {
border-radius: $radius-btn;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
}
//
:deep(.el-input) {
.el-input__wrapper {
border-radius: $radius-input;
}
}
:deep(.el-select) {
.el-input__wrapper {
border-radius: $radius-input;
}
}
:deep(.el-input-number) {
.el-input__wrapper {
border-radius: $radius-input;
}
}
:deep(.el-dialog) {
border-radius: $radius-card;
.el-dialog__header {
padding: 20px 24px 16px;
}
.el-dialog__body {
padding: 20px 24px;
}
.el-dialog__footer {
padding: 16px 24px 20px;
}
}
:deep(.el-table) {
border-radius: $radius-card;
overflow: hidden;
th.el-table__cell {
background: $page-bg;
color: $text-secondary;
font-weight: 600;
}
tr.el-table__row:hover {
td.el-table__cell {
background: #f1f5f9;
}
}
}
:deep(.el-empty) {
padding: 60px 0;
.el-empty__description {
color: $text-muted;
font-size: 15px;
}
}
:deep(.el-tag) {
border-radius: 8px;
}
:deep(.el-skeleton) {
.el-skeleton__item {
border-radius: 8px;
}
}
</style>

View File

@ -0,0 +1,477 @@
<template>
<div class="page-container">
<el-card class="card-header">
<template #header>
<div class="card-header-content">
<span>服务器与实例</span>
<div class="header-buttons">
<el-button type="warning" @click="handleCheckOffline" :loading="checkingOffline">
<el-icon><Refresh /></el-icon>
检查离线实例
</el-button>
<el-button type="success" @click="handleCheckAll" :loading="checkingAll">
<el-icon><Refresh /></el-icon>
检查全部实例
</el-button>
<el-button type="primary" @click="handleRefresh" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新列表
</el-button>
</div>
</div>
</template>
<el-empty v-if="!loading && Object.keys(instancesByServer).length === 0" description="暂无实例数据,请在配置管理中添加" />
<el-skeleton v-else-if="loading" :rows="5" animated />
<template v-else>
<el-collapse v-model="activeServers">
<el-collapse-item
v-for="(instances, serverName) in instancesByServer"
:key="serverName"
:name="serverName"
>
<template #title>
<div class="server-title">
<el-icon><Monitor /></el-icon>
<span class="server-name">{{ serverName }}</span>
<el-tag :type="getServerStatusType(instances)" size="small" class="status-tag">
{{ getServerStatusText(instances) }}
</el-tag>
<span class="instance-count">({{ instances.length }} 个实例)</span>
</div>
</template>
<div class="instances-grid">
<el-card
v-for="instance in instances"
:key="instance.id || instance.name"
class="instance-card"
shadow="hover"
>
<div class="instance-header">
<div class="instance-name">
<el-icon :class="`status-${instance.status || 'offline'}`">
<CircleCheck v-if="instance.status === 'online'" />
<Loading v-else-if="instance.status === 'busy'" />
<CircleClose v-else />
</el-icon>
<span class="name-text">{{ instance.name || instance.id || '未知实例' }}</span>
</div>
<el-tag :type="getStatusType(instance.status)" size="small">
{{ getStatusText(instance.status) }}
</el-tag>
</div>
<div class="instance-info">
<div class="info-item">
<span class="label">主机:</span>
<span class="value">{{ instance.ip || '-' }}</span>
</div>
<div class="info-item">
<span class="label">端口:</span>
<span class="value">{{ instance.port || '-' }}</span>
</div>
<div class="info-item" v-if="instance.load">
<span class="label">负载:</span>
<span class="value">{{ instance.load }}%</span>
</div>
</div>
<div class="instance-actions">
<el-button
type="primary"
size="small"
@click="handleCheckInstance(instance)"
:loading="checkingInstances[instance.id]"
>
<el-icon><Refresh /></el-icon>
检查状态
</el-button>
</div>
</el-card>
</div>
</el-collapse-item>
</el-collapse>
</template>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Monitor, CircleCheck, Loading, CircleClose } from '@element-plus/icons-vue'
import { useInstanceStore } from '@/stores/instance'
const instanceStore = useInstanceStore()
const activeServers = ref([])
const checkingOffline = ref(false)
const checkingAll = ref(false)
const checkingInstances = reactive({})
const instancesByServer = computed(() => instanceStore.getInstancesByServer())
const loading = computed(() => instanceStore.loading)
const getStatusType = (status) => {
const map = {
online: 'success',
busy: 'warning',
offline: 'danger'
}
return map[status] || 'info'
}
const getStatusText = (status) => {
const map = {
online: '在线',
busy: '忙碌',
offline: '离线'
}
return map[status] || '未知'
}
const getServerStatusType = (instances) => {
const hasOnline = instances.some(i => i.status === 'online' || i.status === 'busy')
const hasOffline = instances.some(i => i.status === 'offline')
if (hasOnline && hasOffline) return 'warning'
if (hasOnline) return 'success'
return 'danger'
}
const getServerStatusText = (instances) => {
const onlineCount = instances.filter(i => i.status === 'online' || i.status === 'busy').length
return `${onlineCount}/${instances.length} 在线`
}
const handleRefresh = async () => {
await instanceStore.fetchInstances()
ElMessage.success('刷新成功')
}
const handleCheckInstance = async (instance) => {
checkingInstances[instance.id] = true
try {
await instanceStore.checkInstance(instance.id)
ElMessage.success(`实例 ${instance.name} 状态检查完成`)
} catch (error) {
ElMessage.error(`检查实例 ${instance.name} 状态失败`)
} finally {
checkingInstances[instance.id] = false
}
}
const handleCheckOffline = async () => {
checkingOffline.value = true
try {
await instanceStore.checkOfflineInstances()
ElMessage.success('离线实例检查完成')
} catch (error) {
ElMessage.error('检查离线实例失败')
} finally {
checkingOffline.value = false
}
}
const handleCheckAll = async () => {
checkingAll.value = true
try {
await instanceStore.checkAllInstances()
ElMessage.success('全部实例检查完成')
} catch (error) {
ElMessage.error('检查全部实例失败')
} finally {
checkingAll.value = false
}
}
onMounted(() => {
instanceStore.fetchInstances()
})
</script>
<style scoped lang="scss">
//
$primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
$page-bg: #f8fafc;
$card-bg: #ffffff;
$text-primary: #1e293b;
$text-secondary: #475569;
$text-muted: #94a3b8;
$radius-btn: 12px;
$radius-card: 16px;
$radius-input: 10px;
$shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.08);
.page-container {
height: 100%;
overflow-y: auto;
padding: 24px;
background: $page-bg;
}
.card-header {
border-radius: $radius-card;
box-shadow: $shadow;
border: none;
background: $card-bg;
:deep(.el-card__header) {
background: $primary-gradient;
border-bottom: none;
padding: 20px 24px;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
> span {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
:deep(.el-card__body) {
padding: 24px;
background: $card-bg;
}
}
.header-buttons {
display: flex;
gap: 12px;
}
//
:deep(.el-button) {
border-radius: $radius-btn;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
}
.server-title {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 0;
.el-icon {
font-size: 20px;
color: #6366f1;
}
.server-name {
font-weight: 600;
font-size: 16px;
color: $text-primary;
}
.status-tag {
margin-left: 4px;
font-weight: 500;
}
.instance-count {
color: $text-muted;
font-size: 14px;
font-weight: 500;
}
}
.instances-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 24px;
}
.instance-card {
flex: 0 0 calc(33.333% - 16px);
min-width: 320px;
border-radius: $radius-card;
border: 1px solid #e2e8f0;
background: $card-bg;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: $primary-gradient;
opacity: 0;
transition: opacity 0.2s;
}
&:hover {
transform: translateY(-2px);
box-shadow: $shadow;
border-color: #6366f1;
&:before {
opacity: 1;
}
}
&:deep(.el-card__body) {
padding: 24px;
}
.instance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px dashed #e2e8f0;
.instance-name {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 16px;
color: $text-primary;
.name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.el-icon {
font-size: 20px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
&.status-online {
color: #22c55e;
}
&.status-busy {
color: #f59e0b;
}
&.status-offline {
color: #ef4444;
}
}
}
}
.instance-info {
margin-bottom: 20px;
.info-item {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
color: $text-muted;
width: 56px;
flex-shrink: 0;
font-weight: 500;
}
.value {
color: $text-secondary;
word-break: break-all;
font-family: 'Consolas', 'Monaco', monospace;
}
}
}
.instance-actions {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
}
:deep(.el-collapse) {
border: none;
.el-collapse-item {
border: 1px solid #e2e8f0;
border-radius: $radius-card;
margin-bottom: 16px;
overflow: hidden;
background: $card-bg;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
transition: all 0.2s;
&:last-child {
margin-bottom: 0;
}
&:hover {
box-shadow: $shadow;
}
.el-collapse-item__header {
padding: 16px 20px;
background: linear-gradient(90deg, #f8fafc 0%, #ffffff 100%);
border-bottom: none;
.el-collapse-item__arrow {
color: #6366f1;
}
}
.el-collapse-item__wrap {
border-top: 1px solid #e2e8f0;
background: #fafbfc;
.el-collapse-item__content {
padding: 24px;
}
}
}
}
:deep(.el-skeleton) {
.el-skeleton__item {
border-radius: 8px;
}
}
:deep(.el-empty) {
padding: 60px 0;
.el-empty__description {
color: $text-muted;
font-size: 15px;
}
}
:deep(.el-tag) {
border-radius: 8px;
}
</style>

View File

@ -0,0 +1,243 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<div class="logo-wrapper">
<div class="logo-icon">CB</div>
</div>
<h1>ComfyUI Cluster Bridge</h1>
<p>集群管理后台</p>
</div>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" class="login-form">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-button"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>默认账号: admin / admin123</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
const loginForm = reactive({
username: 'admin',
password: 'admin123'
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
await userStore.login(loginForm.username, loginForm.password)
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
console.error('登录失败:', error)
const errorMessage = error.response?.data?.message || error.message || '登录失败,请检查用户名和密码'
ElMessage.error(errorMessage)
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
@import '@/styles/design-system.scss';
.login-container {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
border-radius: 50%;
top: -200px;
right: -200px;
animation: float 8s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
border-radius: 50%;
bottom: -100px;
left: -100px;
animation: float 10s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-30px) rotate(5deg);
}
}
.login-box {
width: 440px;
padding: $space-40;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: $radius-xl;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.3);
position: relative;
z-index: 1;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: $space-32;
.logo-wrapper {
display: flex;
justify-content: center;
margin-bottom: $space-20;
.logo-icon {
width: 64px;
height: 64px;
background: $primary-gradient;
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
color: $text-white;
font-weight: $font-weight-bold;
font-size: $font-size-xl;
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.4);
}
}
h1 {
font-size: $font-size-2xl;
color: $text-primary;
margin: 0 0 $space-8 0;
font-weight: $font-weight-bold;
letter-spacing: 0.3px;
}
p {
font-size: $font-size-sm;
color: $text-tertiary;
margin: 0;
font-weight: $font-weight-medium;
}
}
.login-form {
.el-form-item {
margin-bottom: $space-24;
}
}
.login-button {
width: 100%;
height: 48px;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
}
.login-footer {
text-align: center;
margin-top: $space-24;
padding-top: $space-20;
border-top: 1px dashed $border-light;
p {
font-size: $font-size-xs;
color: $text-tertiary;
margin: 0;
}
}
</style>

View File

@ -0,0 +1,513 @@
<template>
<div class="page-container">
<el-row :gutter="24" class="stats-row">
<el-col :xs="12" :sm="12" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);">
<el-icon :size="30"><Monitor /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ monitorData.instances?.total || 0 }}</div>
<div class="stat-label">实例总数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<el-icon :size="30"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ monitorData.instances?.online || 0 }}</div>
<div class="stat-label">在线实例</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
<el-icon :size="30"><Loading /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ monitorData.tasks?.running || 0 }}</div>
<div class="stat-label">运行中任务</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<el-icon :size="30"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ monitorData.tasks?.completed || 0 }}</div>
<div class="stat-label">已完成任务</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="24" style="margin-top: 24px;">
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card class="chart-card">
<template #header>
<div class="card-header-content">
<span>实例状态分布</span>
</div>
</template>
<div ref="instanceChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
<el-card class="chart-card">
<template #header>
<div class="card-header-content">
<span>任务状态分布</span>
</div>
</template>
<div ref="taskChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="24" style="margin-top: 24px;">
<el-col :span="24">
<el-card class="chart-card">
<template #header>
<div class="card-header-content">
<span>任务趋势最近24小时</span>
<el-button type="primary" size="small" @click="refreshData">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</template>
<div ref="trendChartRef" class="chart-container" style="height: 300px;"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { Refresh, Monitor, CircleCheck, Loading } from '@element-plus/icons-vue'
import { getMonitorOverview } from '@/api'
const monitorData = ref({
instances: { total: 0, online: 0, busy: 0, offline: 0 },
tasks: { total: 0, pending: 0, running: 0, completed: 0, failed: 0 }
})
const instanceChartRef = ref(null)
const taskChartRef = ref(null)
const trendChartRef = ref(null)
let instanceChart = null
let taskChart = null
let trendChart = null
const trendData = ref({
times: [],
pending: [],
running: [],
completed: []
})
const initTrendData = () => {
const now = new Date()
for (let i = 23; i >= 0; i--) {
const time = new Date(now - i * 60 * 60 * 1000)
trendData.value.times.push(time.getHours() + ':00')
trendData.value.pending.push(Math.floor(Math.random() * 20))
trendData.value.running.push(Math.floor(Math.random() * 10))
trendData.value.completed.push(Math.floor(Math.random() * 50))
}
}
const initInstanceChart = () => {
if (!instanceChartRef.value) return
instanceChart = echarts.init(instanceChartRef.value)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: {
color: '#1e293b'
}
},
legend: {
orient: 'vertical',
left: 'left',
textStyle: {
color: '#475569'
}
},
series: [
{
name: '实例状态',
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 3
},
label: {
show: true,
formatter: '{b}: {c}',
color: '#475569'
},
emphasis: {
label: {
show: true,
fontSize: '16',
fontWeight: 'bold'
}
},
data: [
{ value: monitorData.value.instances.online, name: '在线', itemStyle: { color: '#10b981' } },
{ value: monitorData.value.instances.busy, name: '忙碌', itemStyle: { color: '#f59e0b' } },
{ value: monitorData.value.instances.offline, name: '离线', itemStyle: { color: '#ef4444' } }
]
}
]
}
instanceChart.setOption(option)
}
const initTaskChart = () => {
if (!taskChartRef.value) return
taskChart = echarts.init(taskChartRef.value)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: {
color: '#1e293b'
}
},
legend: {
orient: 'vertical',
left: 'left',
textStyle: {
color: '#475569'
}
},
series: [
{
name: '任务状态',
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 3
},
label: {
show: true,
formatter: '{b}: {c}',
color: '#475569'
},
emphasis: {
label: {
show: true,
fontSize: '16',
fontWeight: 'bold'
}
},
data: [
{ value: monitorData.value.tasks.pending, name: '待处理', itemStyle: { color: '#94a3b8' } },
{ value: monitorData.value.tasks.running, name: '运行中', itemStyle: { color: '#3b82f6' } },
{ value: monitorData.value.tasks.completed, name: '已完成', itemStyle: { color: '#10b981' } },
{ value: monitorData.value.tasks.failed, name: '失败', itemStyle: { color: '#ef4444' } }
]
}
]
}
taskChart.setOption(option)
}
const initTrendChart = () => {
if (!trendChartRef.value) return
trendChart = echarts.init(trendChartRef.value)
const option = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: {
color: '#1e293b'
}
},
legend: {
data: ['待处理', '运行中', '已完成'],
textStyle: {
color: '#475569'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: trendData.value.times,
axisLine: {
lineStyle: {
color: '#e2e8f0'
}
},
axisLabel: {
color: '#94a3b8'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#e2e8f0'
}
},
axisLabel: {
color: '#94a3b8'
},
splitLine: {
lineStyle: {
color: '#f1f5f9'
}
}
},
series: [
{
name: '待处理',
type: 'line',
stack: 'Total',
data: trendData.value.pending,
itemStyle: { color: '#94a3b8' },
smooth: true,
areaStyle: {
opacity: 0.1
}
},
{
name: '运行中',
type: 'line',
stack: 'Total',
data: trendData.value.running,
itemStyle: { color: '#3b82f6' },
smooth: true,
areaStyle: {
opacity: 0.1
}
},
{
name: '已完成',
type: 'line',
stack: 'Total',
data: trendData.value.completed,
itemStyle: { color: '#10b981' },
smooth: true,
areaStyle: {
opacity: 0.1
}
}
]
}
trendChart.setOption(option)
}
const refreshData = async () => {
try {
monitorData.value = await getMonitorOverview()
if (instanceChart) {
instanceChart.setOption({
series: [{
data: [
{ value: monitorData.value.instances.online, name: '在线' },
{ value: monitorData.value.instances.busy, name: '忙碌' },
{ value: monitorData.value.instances.offline, name: '离线' }
]
}]
})
}
if (taskChart) {
taskChart.setOption({
series: [{
data: [
{ value: monitorData.value.tasks.pending, name: '待处理' },
{ value: monitorData.value.tasks.running, name: '运行中' },
{ value: monitorData.value.tasks.completed, name: '已完成' },
{ value: monitorData.value.tasks.failed, name: '失败' }
]
}]
})
}
} catch (error) {
console.error('获取监控数据失败:', error)
}
}
const handleResize = () => {
instanceChart?.resize()
taskChart?.resize()
trendChart?.resize()
}
onMounted(async () => {
initTrendData()
await refreshData()
await nextTick()
initInstanceChart()
initTaskChart()
initTrendChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
instanceChart?.dispose()
taskChart?.dispose()
trendChart?.dispose()
})
</script>
<style scoped lang="scss">
@import '@/styles/design-system.scss';
.page-container {
height: 100%;
overflow-y: auto;
padding: $space-24;
background: $bg-page;
}
.stats-row {
.stat-card {
border-radius: $radius-xl;
border: none;
box-shadow: $shadow-md;
transition: all $transition-base;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-lg;
}
:deep(.el-card__body) {
padding: $space-24;
}
.stat-content {
display: flex;
align-items: center;
gap: $space-16;
.stat-icon {
width: 64px;
height: 64px;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
color: $text-white;
box-shadow: $shadow-md;
flex-shrink: 0;
}
.stat-info {
flex: 1;
.stat-value {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $text-primary;
line-height: 1.2;
}
.stat-label {
font-size: $font-size-sm;
color: $text-tertiary;
margin-top: $space-4;
font-weight: $font-weight-medium;
}
}
}
}
}
.chart-card {
border-radius: $radius-xl;
box-shadow: $shadow-md;
border: 1px solid $border-light;
transition: all $transition-base;
&:hover {
box-shadow: $shadow-lg;
}
:deep(.el-card__header) {
background: linear-gradient(90deg, $bg-card 0%, $bg-surface 100%);
border-bottom: 1px solid $border-light;
padding: $space-16 $space-24;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
> span {
color: $text-primary;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
}
}
:deep(.el-card__body) {
padding: $space-24;
background: $bg-card;
}
}
.chart-container {
height: 300px;
width: 100%;
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<div class="page-container">
<el-card class="card-header">
<template #header>
<div class="card-header-content">
<span>任务管理</span>
<div class="header-actions">
<el-select v-model="filterStatus" placeholder="状态筛选" clearable style="width: 150px; margin-right: 12px;">
<el-option label="全部" value="" />
<el-option label="待处理" value="pending" />
<el-option label="运行中" value="running" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
</el-select>
<el-button type="primary" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
</template>
<el-empty v-if="!loading && tasks.length === 0" description="暂无任务数据" />
<el-table
v-else
:data="tasks"
v-loading="loading"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="任务ID" width="180" show-overflow-tooltip />
<el-table-column prop="instanceId" label="实例ID" width="150" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="150">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="getProgressStatus(row.status)" />
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/task'
const taskStore = useTaskStore()
const filterStatus = ref('')
const tasks = computed(() => taskStore.tasks)
const loading = computed(() => taskStore.loading)
const getStatusType = (status) => {
const map = {
pending: 'info',
running: 'primary',
completed: 'success',
failed: 'danger'
}
return map[status] || 'info'
}
const getStatusText = (status) => {
const map = {
pending: '待处理',
running: '运行中',
completed: '已完成',
failed: '失败'
}
return map[status] || '未知'
}
const getProgressStatus = (status) => {
const map = {
pending: '',
running: '',
completed: 'success',
failed: 'exception'
}
return map[status]
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
const handleRefresh = () => {
taskStore.fetchTasks(filterStatus.value)
}
watch(filterStatus, (newStatus) => {
taskStore.fetchTasks(newStatus)
})
onMounted(() => {
taskStore.fetchTasks()
})
</script>
<style scoped lang="scss">
//
$primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
$page-bg: #f8fafc;
$card-bg: #ffffff;
$text-primary: #1e293b;
$text-secondary: #475569;
$text-muted: #94a3b8;
$radius-btn: 12px;
$radius-card: 16px;
$radius-input: 10px;
$shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.08);
.page-container {
height: 100%;
overflow-y: auto;
padding: 24px;
background: $page-bg;
}
.card-header {
border-radius: $radius-card;
box-shadow: $shadow;
border: none;
background: $card-bg;
:deep(.el-card__header) {
background: $primary-gradient;
border-bottom: none;
padding: 20px 24px;
.card-header-content {
display: flex;
justify-content: space-between;
align-items: center;
> span {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
}
}
:deep(.el-card__body) {
padding: 24px;
background: $card-bg;
}
}
//
:deep(.el-button) {
border-radius: $radius-btn;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
}
//
:deep(.el-select) {
.el-input__wrapper {
border-radius: $radius-input;
}
}
:deep(.el-table) {
border-radius: $radius-card;
overflow: hidden;
th.el-table__cell {
background: $page-bg;
color: $text-secondary;
font-weight: 600;
}
tr.el-table__row:hover {
td.el-table__cell {
background: #f1f5f9;
}
}
}
:deep(.el-empty) {
padding: 60px 0;
.el-empty__description {
color: $text-muted;
font-size: 15px;
}
}
:deep(.el-tag) {
border-radius: 8px;
}
</style>

25
frontend/vite.config.js Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
allowedHosts: ['dbc94f5824804eb9b41c3e7d3586baa2.pvt.hz.smartml.cn'],
proxy: {
'/api': {
target: env.VITE_MESSAGE_DISPATCHER_BASE_URL || 'http://localhost:4000',
changeOrigin: true
}
}
}
}
})

8
message-dispatcher/.env Normal file
View File

@ -0,0 +1,8 @@
PORT=4000
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
JWT_SECRET=comfyui-cluster-bridge-secret-key-2024
JWT_EXPIRES_IN=24h
ADMIN_USERNAME=admin
ADMIN_PASSWORD=2233..2233

View File

@ -0,0 +1,23 @@
{
"name": "comfyui-message-dispatcher",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"ioredis": "^5.3.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@ -0,0 +1,324 @@
import express from 'express';
import bridgeManager from '../bridge-manager/index.js';
import websocketServer from '../websocket-server/index.js';
import { v4 as uuidv4 } from 'uuid';
import logger from '../logger/index.js';
import { authMiddleware } from '../auth/middleware.js';
const router = express.Router();
router.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'ok',
timestamp: new Date().toISOString()
}
});
});
router.get('/bridges', authMiddleware, (req, res) => {
const bridges = bridgeManager.getAllBridges();
res.json({
success: true,
data: bridges
});
});
router.get('/bridges/:bridgeId', authMiddleware, (req, res) => {
const bridge = bridgeManager.getBridge(req.params.bridgeId);
if (!bridge) {
return res.status(404).json({
success: false,
error: '桥接器不存在'
});
}
res.json({
success: true,
data: {
id: bridge.id,
info: bridge.info,
connectedAt: bridge.connectedAt,
lastHeartbeat: bridge.lastHeartbeat
}
});
});
router.post('/task', authMiddleware, async (req, res) => {
try {
const { bridgeId, workflowId, nodeInfoList, webhookUrl } = req.body;
if (!bridgeId) {
return res.status(400).json({
success: false,
error: 'bridgeId不能为空'
});
}
const bridge = bridgeManager.getBridge(bridgeId);
if (!bridge) {
return res.status(404).json({
success: false,
error: '桥接器不存在'
});
}
const requestId = uuidv4();
logger.info(`收到任务请求, bridgeId: ${bridgeId}, requestId: ${requestId}`);
const result = await websocketServer.sendTaskToBridge(
bridgeId,
{ workflowId, nodeInfoList, webhookUrl },
requestId
);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('处理任务请求失败:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
router.get('/instances', authMiddleware, (req, res) => {
const bridges = bridgeManager.getAllBridges();
const allInstances = [];
for (const bridge of bridges) {
if (bridge.info?.instances) {
for (const instance of bridge.info.instances) {
allInstances.push({
...instance,
bridgeId: bridge.id
});
}
}
}
res.json({
success: true,
data: allInstances
});
});
router.get('/overview', authMiddleware, (req, res) => {
const bridges = bridgeManager.getAllBridges();
let totalInstances = 0;
let onlineInstances = 0;
let busyInstances = 0;
let offlineInstances = 0;
for (const bridge of bridges) {
if (bridge.info?.instances) {
totalInstances += bridge.info.instances.length;
onlineInstances += bridge.info.instances.filter(i => i.status === 'online').length;
busyInstances += bridge.info.instances.filter(i => i.status === 'busy').length;
offlineInstances += bridge.info.instances.filter(i => i.status === 'offline').length;
}
}
res.json({
success: true,
data: {
bridges: {
total: bridges.length,
online: bridges.length
},
instances: {
total: totalInstances,
online: onlineInstances,
busy: busyInstances,
offline: offlineInstances
},
tasks: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0
}
}
});
});
router.get('/monitor/overview', authMiddleware, (req, res) => {
const bridges = bridgeManager.getAllBridges();
let totalInstances = 0;
let onlineInstances = 0;
let busyInstances = 0;
let offlineInstances = 0;
for (const bridge of bridges) {
if (bridge.info?.instances) {
totalInstances += bridge.info.instances.length;
onlineInstances += bridge.info.instances.filter(i => i.status === 'online').length;
busyInstances += bridge.info.instances.filter(i => i.status === 'busy').length;
offlineInstances += bridge.info.instances.filter(i => i.status === 'offline').length;
}
}
res.json({
success: true,
data: {
instances: {
total: totalInstances,
online: onlineInstances,
busy: busyInstances,
offline: offlineInstances
},
tasks: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0
}
}
});
});
router.get('/tasks', authMiddleware, (req, res) => {
res.json({
success: true,
data: []
});
});
router.post('/instances/:instanceId/health-check', authMiddleware, async (req, res) => {
try {
const { instanceId } = req.params;
const bridges = bridgeManager.getAllBridges();
let targetBridgeId = null;
for (const bridge of bridges) {
if (bridge.info?.instances) {
const instance = bridge.info.instances.find(i => i.id === instanceId);
if (instance) {
targetBridgeId = bridge.id;
break;
}
}
}
if (!targetBridgeId) {
return res.status(404).json({
success: false,
error: '实例不存在'
});
}
const requestId = uuidv4();
logger.info(`收到实例健康检查请求, instanceId: ${instanceId}, bridgeId: ${targetBridgeId}`);
const result = await websocketServer.sendInstanceCheckToBridge(
targetBridgeId,
'single',
instanceId,
requestId
);
res.json({
success: true,
data: result.data
});
} catch (error) {
logger.error('处理实例健康检查失败:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
router.post('/instances/health-check-offline', authMiddleware, async (req, res) => {
try {
const bridges = bridgeManager.getAllBridges();
const results = [];
for (const bridge of bridges) {
const requestId = uuidv4();
logger.info(`收到离线实例检查请求, bridgeId: ${bridge.id}`);
try {
const result = await websocketServer.sendInstanceCheckToBridge(
bridge.id,
'offline',
null,
requestId
);
results.push({
bridgeId: bridge.id,
success: true,
data: result.data
});
} catch (error) {
results.push({
bridgeId: bridge.id,
success: false,
error: error.message
});
}
}
res.json({
success: true,
data: results
});
} catch (error) {
logger.error('处理离线实例检查失败:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
router.post('/instances/health-check-all', authMiddleware, async (req, res) => {
try {
const bridges = bridgeManager.getAllBridges();
const results = [];
for (const bridge of bridges) {
const requestId = uuidv4();
logger.info(`收到全部实例检查请求, bridgeId: ${bridge.id}`);
try {
const result = await websocketServer.sendInstanceCheckToBridge(
bridge.id,
'all',
null,
requestId
);
results.push({
bridgeId: bridge.id,
success: true,
data: result.data
});
} catch (error) {
results.push({
bridgeId: bridge.id,
success: false,
error: error.message
});
}
}
res.json({
success: true,
data: results
});
} catch (error) {
logger.error('处理全部实例检查失败:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
export default router;

View File

@ -0,0 +1,4 @@
import authRoutes from './routes.js';
export { authRoutes };
export default authRoutes;

View File

@ -0,0 +1,96 @@
import jwt from 'jsonwebtoken';
import Redis from 'ioredis';
const JWT_SECRET = process.env.JWT_SECRET || 'comfyui-cluster-bridge-secret-key-2024';
const ACCESS_TOKEN_EXPIRES_IN = '30m';
const REFRESH_TOKEN_EXPIRES_IN = '7d';
const BLACKLIST_KEY_PREFIX = 'jwt:blacklist:';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DB || '0')
});
redis.on('error', (err) => {
console.error('Redis 连接错误:', err);
});
function generateAccessToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
}
function generateRefreshToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
}
async function verifyToken(token) {
try {
const isBlacklisted = await redis.get(BLACKLIST_KEY_PREFIX + token);
if (isBlacklisted) {
return null;
}
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
async function addToBlacklist(token, expiresIn = ACCESS_TOKEN_EXPIRES_IN) {
try {
const decoded = jwt.decode(token);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(BLACKLIST_KEY_PREFIX + token, ttl, '1');
}
}
} catch (error) {
console.error('添加黑名单失败:', error);
}
}
async function refreshTokens(refreshToken) {
const payload = await verifyToken(refreshToken);
if (!payload) {
return null;
}
await addToBlacklist(refreshToken, REFRESH_TOKEN_EXPIRES_IN);
const newPayload = { userId: payload.userId, username: payload.username, role: payload.role };
const newAccessToken = generateAccessToken(newPayload);
const newRefreshToken = generateRefreshToken(newPayload);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken
};
}
function generateTokenPair(payload) {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload)
};
}
export {
generateAccessToken,
generateRefreshToken,
generateTokenPair,
verifyToken,
addToBlacklist,
refreshTokens
};
export default {
generateAccessToken,
generateRefreshToken,
generateTokenPair,
verifyToken,
addToBlacklist,
refreshTokens,
redis
};

View File

@ -0,0 +1,39 @@
import { verifyToken } from './jwt.js';
function createResponse(success, message, data = null) {
return { success, message, data };
}
async function authMiddleware(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(createResponse(false, '未提供身份验证令牌'));
}
const token = authHeader.substring(7);
const payload = await verifyToken(token);
if (!payload) {
return res.status(401).json(createResponse(false, '无效或已过期的令牌'));
}
req.user = payload;
req.token = token;
next();
} catch (error) {
console.error('身份验证中间件错误:', error);
res.status(500).json(createResponse(false, '身份验证失败'));
}
}
export {
authMiddleware,
createResponse
};
export default {
authMiddleware,
createResponse
};

View File

@ -0,0 +1,5 @@
import bcryptjs from 'bcryptjs';
export async function verifyPassword(password, hashedPassword) {
return await bcryptjs.compare(password, hashedPassword);
}

View File

@ -0,0 +1,103 @@
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DB || '0')
});
redis.on('error', (err) => {
console.error('Redis 连接错误 (rate-limit):', err);
});
const RATE_LIMIT_PREFIX = 'login:fail:';
const MAX_ATTEMPTS = 5;
const WINDOW_SECONDS = 600;
async function getFailAttempts(key) {
try {
const count = await redis.get(RATE_LIMIT_PREFIX + key);
return count ? parseInt(count) : 0;
} catch (error) {
console.error('获取失败次数出错:', error);
return 0;
}
}
async function incrementFailAttempts(key) {
try {
const exists = await redis.exists(RATE_LIMIT_PREFIX + key);
if (exists) {
return await redis.incr(RATE_LIMIT_PREFIX + key);
} else {
await redis.setex(RATE_LIMIT_PREFIX + key, WINDOW_SECONDS, '1');
return 1;
}
} catch (error) {
console.error('增加失败次数出错:', error);
return 1;
}
}
async function resetFailAttempts(key) {
try {
await redis.del(RATE_LIMIT_PREFIX + key);
} catch (error) {
console.error('重置失败次数出错:', error);
}
}
async function checkLoginRateLimit(username, ip) {
const keys = [];
if (username) keys.push(`user:${username}`);
if (ip) keys.push(`ip:${ip}`);
for (const key of keys) {
const attempts = await getFailAttempts(key);
if (attempts >= MAX_ATTEMPTS) {
const ttl = await redis.ttl(RATE_LIMIT_PREFIX + key);
const minutes = Math.ceil(ttl / 60);
return {
allowed: false,
message: `登录尝试次数过多,请在 ${minutes} 分钟后重试`
};
}
}
let minRemaining = MAX_ATTEMPTS;
for (const key of keys) {
const attempts = await getFailAttempts(key);
const remaining = MAX_ATTEMPTS - attempts;
if (remaining < minRemaining) {
minRemaining = remaining;
}
}
return {
allowed: true,
remaining: minRemaining
};
}
async function recordLoginFailure(username, ip) {
if (username) await incrementFailAttempts(`user:${username}`);
if (ip) await incrementFailAttempts(`ip:${ip}`);
}
async function clearLoginFailures(username, ip) {
if (username) await resetFailAttempts(`user:${username}`);
if (ip) await resetFailAttempts(`ip:${ip}`);
}
export {
checkLoginRateLimit,
recordLoginFailure,
clearLoginFailures
};
export default {
checkLoginRateLimit,
recordLoginFailure,
clearLoginFailures,
redis
};

View File

@ -0,0 +1,130 @@
import express from 'express';
import bcryptjs from 'bcryptjs';
import { verifyPassword } from './password.js';
import { generateTokenPair, addToBlacklist, refreshTokens } from './jwt.js';
import { authMiddleware, createResponse } from './middleware.js';
import { checkLoginRateLimit, recordLoginFailure, clearLoginFailures } from './rate-limit.js';
const router = express.Router();
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
let hashedAdminPassword = null;
(async () => {
try {
hashedAdminPassword = await bcryptjs.hash(ADMIN_PASSWORD, 12);
console.log('管理员密码已哈希');
} catch (error) {
console.error('哈希管理员密码失败:', error);
hashedAdminPassword = ADMIN_PASSWORD;
}
})();
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
const rateLimit = await checkLoginRateLimit(username, ip);
if (!rateLimit.allowed) {
return res.status(429).json(createResponse(false, rateLimit.message));
}
if (!username || !password) {
return res.status(400).json(createResponse(false, '用户名和密码不能为空'));
}
if (username !== ADMIN_USERNAME) {
await recordLoginFailure(username, ip);
return res.status(401).json(createResponse(false, '用户名或密码错误'));
}
let passwordValid = false;
if (hashedAdminPassword && hashedAdminPassword !== ADMIN_PASSWORD) {
passwordValid = await bcryptjs.compare(password, hashedAdminPassword);
} else {
passwordValid = (password === ADMIN_PASSWORD);
}
if (!passwordValid) {
await recordLoginFailure(username, ip);
return res.status(401).json(createResponse(false, '用户名或密码错误'));
}
await clearLoginFailures(username, ip);
const payload = {
userId: '1',
username: ADMIN_USERNAME,
role: 'admin'
};
const tokens = generateTokenPair(payload);
res.json(createResponse(true, '登录成功', {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: {
id: '1',
username: ADMIN_USERNAME,
role: 'admin'
}
}));
} catch (error) {
console.error('登录错误:', error);
res.status(500).json(createResponse(false, '服务器内部错误'));
}
});
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json(createResponse(false, '刷新令牌不能为空'));
}
const newTokens = await refreshTokens(refreshToken);
if (!newTokens) {
return res.status(401).json(createResponse(false, '无效或已过期的刷新令牌'));
}
res.json(createResponse(true, '令牌刷新成功', {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken
}));
} catch (error) {
console.error('刷新令牌错误:', error);
res.status(500).json(createResponse(false, '服务器内部错误'));
}
});
router.post('/logout', authMiddleware, async (req, res) => {
try {
const { refreshToken } = req.body;
if (req.token) {
await addToBlacklist(req.token, '30m');
}
if (refreshToken) {
await addToBlacklist(refreshToken, '7d');
}
res.json(createResponse(true, '登出成功'));
} catch (error) {
console.error('登出错误:', error);
res.status(500).json(createResponse(false, '服务器内部错误'));
}
});
router.get('/me', authMiddleware, (req, res) => {
res.json(createResponse(true, '获取用户信息成功', {
id: req.user.userId,
username: req.user.username,
role: req.user.role
}));
});
export default router;

View File

@ -0,0 +1,82 @@
import logger from '../logger/index.js';
class BridgeManager {
constructor() {
this.bridges = new Map();
}
registerBridge(bridgeId, ws, bridgeInfo) {
const bridge = {
id: bridgeId,
ws,
info: bridgeInfo,
connectedAt: new Date().toISOString(),
lastHeartbeat: new Date().toISOString()
};
this.bridges.set(bridgeId, bridge);
logger.info(`桥接器已注册: ${bridgeId}`);
return bridge;
}
unregisterBridge(bridgeId) {
if (this.bridges.has(bridgeId)) {
this.bridges.delete(bridgeId);
logger.info(`桥接器已注销: ${bridgeId}`);
}
}
updateHeartbeat(bridgeId) {
const bridge = this.bridges.get(bridgeId);
if (bridge) {
bridge.lastHeartbeat = new Date().toISOString();
}
}
getBridge(bridgeId) {
return this.bridges.get(bridgeId) || null;
}
getAllBridges() {
return Array.from(this.bridges.values()).map(b => ({
id: b.id,
info: b.info,
connectedAt: b.connectedAt,
lastHeartbeat: b.lastHeartbeat
}));
}
getOnlineBridges() {
return this.getAllBridges();
}
sendToBridge(bridgeId, message) {
const bridge = this.bridges.get(bridgeId);
if (!bridge) {
logger.warn(`桥接器不存在: ${bridgeId}`);
return false;
}
if (bridge.ws.readyState !== 1) {
logger.warn(`桥接器连接不可用: ${bridgeId}`);
return false;
}
try {
bridge.ws.send(JSON.stringify(message));
return true;
} catch (error) {
logger.error(`发送消息到桥接器失败: ${bridgeId}`, error);
return false;
}
}
broadcast(message) {
let successCount = 0;
for (const [bridgeId, bridge] of this.bridges) {
if (this.sendToBridge(bridgeId, message)) {
successCount++;
}
}
return successCount;
}
}
export default new BridgeManager();

View File

@ -0,0 +1,36 @@
import express from 'express';
import cors from 'cors';
import 'dotenv/config';
import authRoutes from './auth/index.js';
import apiRoutes from './api/index.js';
import logger from './logger/index.js';
import websocketServer from './websocket-server/index.js';
const app = express();
const PORT = process.env.PORT || 4000;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
app.get('/', (req, res) => {
res.json({
name: 'ComfyUI Message Dispatcher',
version: '1.0.0',
status: 'running',
timestamp: new Date().toISOString()
});
});
const server = app.listen(PORT, () => {
console.log('========================================');
console.log('ComfyUI Message Dispatcher 已启动');
console.log(`服务地址: http://localhost:${PORT}`);
console.log('========================================');
});
websocketServer.start(server);

View File

@ -0,0 +1,16 @@
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message }) => {
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
})
),
transports: [
new winston.transports.Console()
]
});
export default logger;

View File

@ -0,0 +1,167 @@
import { WebSocketServer as WSServer } from 'ws';
import logger from '../logger/index.js';
import bridgeManager from '../bridge-manager/index.js';
import { v4 as uuidv4 } from 'uuid';
class WebSocketServer {
constructor() {
this.wss = null;
this.pendingRequests = new Map();
}
start(server) {
this.wss = new WSServer({ server });
logger.info('WebSocket服务器已启动');
this.wss.on('connection', (ws) => {
this.handleConnection(ws);
});
}
handleConnection(ws) {
let bridgeId = null;
logger.info('新的WebSocket连接已建立');
ws.on('message', (data) => {
this.handleMessage(ws, data, (id) => { bridgeId = id; });
});
ws.on('close', (code, reason) => {
if (bridgeId) {
bridgeManager.unregisterBridge(bridgeId);
this.cleanupPendingRequests(bridgeId);
}
logger.info(`WebSocket连接已关闭 (code: ${code})`);
});
ws.on('error', (error) => {
logger.error('WebSocket连接错误:', error);
});
}
handleMessage(ws, data, setBridgeId) {
try {
const message = JSON.parse(data.toString());
logger.debug(`收到消息: ${message.type}`);
switch (message.type) {
case 'REGISTER':
this.handleRegister(ws, message, setBridgeId);
break;
case 'HEARTBEAT':
this.handleHeartbeat(message);
break;
case 'TASK_ACK':
case 'TASK_END':
case 'INSTANCE_CHECK_ACK':
this.handleBridgeResponse(message);
break;
case 'PONG':
break;
default:
logger.debug('未知消息类型:', message.type);
}
} catch (error) {
logger.error('解析消息失败:', error);
}
}
handleRegister(ws, message, setBridgeId) {
const bridgeId = message.data?.bridgeId || uuidv4();
setBridgeId(bridgeId);
bridgeManager.registerBridge(bridgeId, ws, message.data);
const response = {
type: 'REGISTER_ACK',
data: {
bridgeId,
timestamp: new Date().toISOString()
}
};
ws.send(JSON.stringify(response));
}
handleHeartbeat(message) {
const bridgeId = message.data?.bridgeId;
if (bridgeId) {
bridgeManager.updateHeartbeat(bridgeId);
}
}
handleBridgeResponse(message) {
const requestId = message.data?.requestId;
if (requestId && this.pendingRequests.has(requestId)) {
const pending = this.pendingRequests.get(requestId);
pending.resolve(message);
this.pendingRequests.delete(requestId);
}
}
sendTaskToBridge(bridgeId, taskData, requestId) {
return new Promise((resolve, reject) => {
const message = {
type: 'TASK_ASSIGN',
data: {
...taskData,
requestId
}
};
const success = bridgeManager.sendToBridge(bridgeId, message);
if (!success) {
reject(new Error('发送任务失败'));
return;
}
const timeout = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('任务执行超时'));
}
}, 5 * 60 * 1000);
this.pendingRequests.set(requestId, { resolve, reject, timeout, bridgeId });
});
}
sendInstanceCheckToBridge(bridgeId, checkType, instanceId, requestId) {
return new Promise((resolve, reject) => {
const message = {
type: 'INSTANCE_CHECK',
data: {
checkType,
instanceId,
requestId
}
};
const success = bridgeManager.sendToBridge(bridgeId, message);
if (!success) {
reject(new Error('发送实例检查请求失败'));
return;
}
const timeout = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('实例检查超时'));
}
}, 30000);
this.pendingRequests.set(requestId, { resolve, reject, timeout, bridgeId });
});
}
cleanupPendingRequests(bridgeId) {
for (const [requestId, pending] of this.pendingRequests) {
if (pending.bridgeId === bridgeId) {
clearTimeout(pending.timeout);
pending.reject(new Error('桥接器连接已断开'));
this.pendingRequests.delete(requestId);
}
}
}
}
export default new WebSocketServer();

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "comfyui-cluster-bridge",
"version": "1.0.0",
"description": "ComfyUI实例集群通信中间层",
"main": "backend/src/index.js",
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "nodemon backend/src/index.js",
"dev:frontend": "cd frontend && vite",
"build:frontend": "cd frontend && vite build",
"start": "node backend/src/index.js",
"test": "vitest"
},
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"axios": "^1.6.2",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5",
"morgan": "^1.10.0",
"winston": "^3.11.0",
"form-data": "^4.0.0",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"nodemon": "^3.0.2",
"concurrently": "^8.2.2",
"vitest": "^1.1.0"
},
"keywords": ["comfyui", "cluster", "websocket"],
"author": "",
"license": "MIT"
}