This commit is contained in:
commit
08424049da
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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. **幂等性**:考虑实现回调接口的幂等性,防止重复处理
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# 默认环境配置
|
||||
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api
|
||||
VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# 默认环境配置
|
||||
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api
|
||||
VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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 已废弃'))
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import authRoutes from './routes.js';
|
||||
|
||||
export { authRoutes };
|
||||
export default authRoutes;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import bcryptjs from 'bcryptjs';
|
||||
|
||||
export async function verifyPassword(password, hashedPassword) {
|
||||
return await bcryptjs.compare(password, hashedPassword);
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue