From 08424049da96831068d65d4902ae87fa2084fb35 Mon Sep 17 00:00:00 2001 From: WangLeo <690854599@qq.com> Date: Fri, 13 Mar 2026 18:38:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 335 ++++ ComfyUI_API_Documentation.md | 1624 +++++++++++++++++ README.md | 183 ++ backend/.env | 10 + backend/config/servers.json | 30 + backend/docs/任务队列集成提示词.md | 306 ++++ backend/package.json | 24 + backend/pnpm-lock.yaml | 1435 +++++++++++++++ backend/src/cluster-manager/index.js | 293 +++ backend/src/comfyui-monitor/index.js | 88 + backend/src/config/index.js | 271 +++ backend/src/data-sync/index.js | 58 + backend/src/file-uploader/index.js | 148 ++ backend/src/index.js | 37 + backend/src/json-persistence/index.js | 121 ++ backend/src/logger/index.js | 54 + backend/src/redis-manager/index.js | 164 ++ backend/src/task-forwarder/index.js | 324 ++++ backend/src/task-queue-client/index.js | 361 ++++ backend/src/websocket-client/index.js | 171 ++ frontend/.env | 3 + frontend/.env.development | 3 + frontend/.env.production | 2 + frontend/index.html | 13 + frontend/package.json | 24 + frontend/pnpm-lock.yaml | 1404 ++++++++++++++ frontend/src/App.vue | 17 + frontend/src/api/index.js | 233 +++ frontend/src/layouts/MainLayout.vue | 278 +++ frontend/src/main.js | 22 + frontend/src/router/index.js | 62 + frontend/src/stores/config.js | 35 + frontend/src/stores/instance.js | 73 + frontend/src/stores/task.js | 36 + frontend/src/stores/user.js | 81 + frontend/src/styles/design-system.scss | 247 +++ frontend/src/styles/index.scss | 168 ++ frontend/src/utils/request.js | 91 + frontend/src/views/Config.vue | 339 ++++ frontend/src/views/Instances.vue | 477 +++++ frontend/src/views/Login.vue | 243 +++ frontend/src/views/Monitor.vue | 513 ++++++ frontend/src/views/Tasks.vue | 224 +++ frontend/vite.config.js | 25 + message-dispatcher/.env | 8 + message-dispatcher/package.json | 23 + message-dispatcher/src/api/index.js | 324 ++++ message-dispatcher/src/auth/index.js | 4 + message-dispatcher/src/auth/jwt.js | 96 + message-dispatcher/src/auth/middleware.js | 39 + message-dispatcher/src/auth/password.js | 5 + message-dispatcher/src/auth/rate-limit.js | 103 ++ message-dispatcher/src/auth/routes.js | 130 ++ .../src/bridge-manager/index.js | 82 + message-dispatcher/src/index.js | 36 + message-dispatcher/src/logger/index.js | 16 + .../src/websocket-server/index.js | 167 ++ package.json | 34 + 58 files changed, 11717 insertions(+) create mode 100644 API.md create mode 100644 ComfyUI_API_Documentation.md create mode 100644 README.md create mode 100644 backend/.env create mode 100644 backend/config/servers.json create mode 100644 backend/docs/任务队列集成提示词.md create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml create mode 100644 backend/src/cluster-manager/index.js create mode 100644 backend/src/comfyui-monitor/index.js create mode 100644 backend/src/config/index.js create mode 100644 backend/src/data-sync/index.js create mode 100644 backend/src/file-uploader/index.js create mode 100644 backend/src/index.js create mode 100644 backend/src/json-persistence/index.js create mode 100644 backend/src/logger/index.js create mode 100644 backend/src/redis-manager/index.js create mode 100644 backend/src/task-forwarder/index.js create mode 100644 backend/src/task-queue-client/index.js create mode 100644 backend/src/websocket-client/index.js create mode 100644 frontend/.env create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/config.js create mode 100644 frontend/src/stores/instance.js create mode 100644 frontend/src/stores/task.js create mode 100644 frontend/src/stores/user.js create mode 100644 frontend/src/styles/design-system.scss create mode 100644 frontend/src/styles/index.scss create mode 100644 frontend/src/utils/request.js create mode 100644 frontend/src/views/Config.vue create mode 100644 frontend/src/views/Instances.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Monitor.vue create mode 100644 frontend/src/views/Tasks.vue create mode 100644 frontend/vite.config.js create mode 100644 message-dispatcher/.env create mode 100644 message-dispatcher/package.json create mode 100644 message-dispatcher/src/api/index.js create mode 100644 message-dispatcher/src/auth/index.js create mode 100644 message-dispatcher/src/auth/jwt.js create mode 100644 message-dispatcher/src/auth/middleware.js create mode 100644 message-dispatcher/src/auth/password.js create mode 100644 message-dispatcher/src/auth/rate-limit.js create mode 100644 message-dispatcher/src/auth/routes.js create mode 100644 message-dispatcher/src/bridge-manager/index.js create mode 100644 message-dispatcher/src/index.js create mode 100644 message-dispatcher/src/logger/index.js create mode 100644 message-dispatcher/src/websocket-server/index.js create mode 100644 package.json diff --git a/API.md b/API.md new file mode 100644 index 0000000..b18e013 --- /dev/null +++ b/API.md @@ -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 +``` + +--- + +## 实例管理 + +### 获取所有实例列表 + +```http +GET /api/instances +Authorization: Bearer +``` + +响应: +```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 +``` + +### 启用/禁用实例 + +```http +PUT /api/instances/:instanceId +Authorization: Bearer +Content-Type: application/json + +{ + "enabled": false +} +``` + +--- + +## 任务管理 + +### 获取任务列表 + +```http +GET /api/tasks?status=pending&limit=20&offset=0 +Authorization: Bearer +``` + +查询参数: +- `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 +Content-Type: application/json + +{ + "workflow": {}, + "params": {} +} +``` + +### 获取任务详情 + +```http +GET /api/tasks/:taskId +Authorization: Bearer +``` + +### 取消任务 + +```http +DELETE /api/tasks/:taskId +Authorization: Bearer +``` + +--- + +## 文件管理 + +### 上传文件 + +```http +POST /api/files/upload +Authorization: Bearer +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 +``` + +### 删除文件 + +```http +DELETE /api/files/:fileId +Authorization: Bearer +``` + +--- + +## 配置管理 + +### 获取配置 + +```http +GET /api/config +Authorization: Bearer +``` + +### 更新配置 + +```http +PUT /api/config +Authorization: Bearer +Content-Type: application/json + +{ + "servers": [...], + "healthCheck": { + "interval": 30000 + } +} +``` + +--- + +## 监控 + +### 获取监控概览 + +```http +GET /api/monitor/overview +Authorization: Bearer +``` + +响应: +```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" + } +} +``` diff --git a/ComfyUI_API_Documentation.md b/ComfyUI_API_Documentation.md new file mode 100644 index 0000000..a158cc6 --- /dev/null +++ b/ComfyUI_API_Documentation.md @@ -0,0 +1,1624 @@ +# ComfyUI 后端 API 接口文档 + +## 目录 + +- [1. WebSocket 连接](#1-websocket-连接) +- [2. 基础接口](#2-基础接口) + - [2.1 获取根页面](#21-获取根页面) + - [2.2 获取 Embeddings](#22-获取-embeddings) + - [2.3 列出模型类型](#23-列出模型类型) + - [2.4 获取指定文件夹的模型](#24-获取指定文件夹的模型) + - [2.5 获取扩展](#25-获取扩展) + - [2.6 查看图片](#26-查看图片) + - [2.7 查看元数据](#27-查看元数据) + - [2.8 系统状态](#28-系统状态) + - [2.9 获取特性](#29-获取特性) + - [2.10 获取提示信息](#210-获取提示信息) + - [2.11 获取对象信息](#211-获取对象信息) + - [2.12 获取特定节点信息](#212-获取特定节点信息) +- [3. 文件上传接口](#3-文件上传接口) + - [3.1 上传图片](#31-上传图片) + - [3.2 上传 Mask](#32-上传-mask) +- [4. 任务管理接口](#4-任务管理接口) + - [4.1 获取所有任务](#41-获取所有任务) + - [4.2 获取特定任务](#42-获取特定任务) + - [4.3 获取历史记录](#43-获取历史记录) + - [4.4 获取特定历史记录](#44-获取特定历史记录) + - [4.5 获取队列](#45-获取队列) + - [4.6 提交提示](#46-提交提示) + - [4.7 队列操作](#47-队列操作) + - [4.8 中断任务](#48-中断任务) + - [4.9 释放资源](#49-释放资源) + - [4.10 历史记录操作](#410-历史记录操作) +- [5. 内部接口](#5-内部接口) + - [5.1 获取日志](#51-获取日志) + - [5.2 获取原始日志](#52-获取原始日志) + - [5.3 订阅日志](#53-订阅日志) + - [5.4 获取文件夹路径](#54-获取文件夹路径) + - [5.5 获取文件列表](#55-获取文件列表) +- [6. 任务提交与结果获取流程](#6-任务提交与结果获取流程) + - [6.1 流程接口概述](#61-流程接口概述) + - [6.2 任务状态说明](#62-任务状态说明) + - [6.3 完整任务生命周期调用示例](#63-完整任务生命周期调用示例) + +--- + +## 1. WebSocket 连接 + +### 1.1 WebSocket 连接 + +**接口路径:** `GET /ws` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| clientId | string | 否 | 客户端ID,用于重连现有会话 | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +- WebSocket 连接建立后,服务器会发送初始状态消息 +- 支持接收和发送 JSON 格式的消息 +- 支持二进制消息传输(预览图片等) + +**错误码及错误信息:** +- 连接失败:WebSocket 连接异常 + +**说明:** +- 建立 WebSocket 连接用于实时通信 +- 如果提供 clientId,会重用现有会话 +- 首次连接时会发送服务器特性标志 +- 支持实时接收任务状态更新和执行进度 + +--- + +## 2. 基础接口 + +### 2.1 获取根页面 + +**接口路径:** `GET /` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +- 返回 HTML 页面(index.html) + +**错误码及错误信息:** +- 404:页面不存在 + +**说明:** +- 返回 ComfyUI 的前端主页 +- 设置了不缓存的响应头 + +--- + +### 2.2 获取 Embeddings + +**接口路径:** `GET /embeddings` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +[ + "embedding1", + "embedding2", + ... +] +``` + +**字段说明:** +- 返回 embeddings 文件夹中所有文件的名称(不含扩展名) + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取所有可用的 embedding 文件列表 + +--- + +### 2.3 列出模型类型 + +**接口路径:** `GET /models` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +[ + "checkpoints", + "loras", + "embeddings", + "vae", + ... +] +``` + +**字段说明:** +- 返回所有模型文件夹类型的名称 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取所有可用的模型类型列表 + +--- + +### 2.4 获取指定文件夹的模型 + +**接口路径:** `GET /models/{folder}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| folder | string | 是 | 模型文件夹名称(路径参数) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +[ + "model1.safetensors", + "model2.safetensors", + ... +] +``` + +**字段说明:** +- 返回指定文件夹中的所有模型文件 + +**错误码及错误信息:** +- 404:指定的文件夹不存在 + +**说明:** +- 获取指定类型文件夹中的所有模型文件 + +--- + +### 2.5 获取扩展 + +**接口路径:** `GET /extensions` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +[ + "/extensions/core/extension1.js", + "/extensions/custom/extension2.js", + ... +] +``` + +**字段说明:** +- 返回所有 JavaScript 扩展文件的路径 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取前端扩展文件列表,包括核心扩展和自定义节点扩展 + +--- + +### 2.6 查看图片 + +**接口路径:** `GET /view` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| filename | string | 是 | 文件名 | +| type | string | 否 | 文件类型(output/input/temp),默认 output | +| subfolder | string | 否 | 子文件夹路径 | +| preview | string | 否 | 预览参数(格式:image_format;quality) | +| channel | string | 否 | 通道类型(rgba/rgb/a),默认 rgba | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +- 返回图片文件或预览图片 +- Content-Type 根据图片格式自动设置 + +**错误码及错误信息:** +- 400:文件名无效或路径不合法 +- 403:访问权限不足 +- 404:文件不存在 + +**说明:** +- 查看或下载生成的图片 +- 支持预览格式转换和质量调整 +- 支持通道分离(RGB、Alpha) + +--- + +### 2.7 查看元数据 + +**接口路径:** `GET /view_metadata/{folder_name}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| folder_name | string | 是 | 文件夹名称(路径参数) | +| filename | string | 是 | 文件名(查询参数) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "__metadata__": { + "key1": "value1", + "key2": "value2" + } +} +``` + +**字段说明:** +- 返回 safetensors 文件的元数据 + +**错误码及错误信息:** +- 404:文件不存在或不是 safetensors 格式 + +**说明:** +- 获取 safetensors 模型文件的元数据信息 + +--- + +### 2.8 系统状态 + +**接口路径:** `GET /system_stats` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "system": { + "os": "windows", + "ram_total": 17179869184, + "ram_free": 8589934592, + "comfyui_version": "0.2.0", + "required_frontend_version": "1.2.0", + "installed_templates_version": "1.0.0", + "required_templates_version": "1.0.0", + "python_version": "3.10.0", + "pytorch_version": "2.0.0", + "embedded_python": false, + "argv": [] + }, + "devices": [ + { + "name": "cuda:0", + "type": "cuda", + "index": 0, + "vram_total": 8589934592, + "vram_free": 4294967296, + "torch_vram_total": 8589934592, + "torch_vram_free": 4294967296 + } + ] +} +``` + +**字段说明:** +- system:系统信息 + - os:操作系统类型 + - ram_total:总内存(字节) + - ram_free:可用内存(字节) + - comfyui_version:ComfyUI 版本 + - required_frontend_version:所需前端版本 + - installed_templates_version:已安装模板版本 + - required_templates_version:所需模板版本 + - python_version:Python 版本 + - pytorch_version:PyTorch 版本 + - embedded_python:是否使用嵌入式 Python + - argv:启动参数 +- devices:设备信息数组 + - name:设备名称 + - type:设备类型 + - index:设备索引 + - vram_total:总显存(字节) + - vram_free:可用显存(字节) + - torch_vram_total:PyTorch 总显存(字节) + - torch_vram_free:PyTorch 可用显存(字节) + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取系统资源使用情况和版本信息 + +--- + +### 2.9 获取特性 + +**接口路径:** `GET /features` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "custom_nodes_from_web": true, + ... +} +``` + +**字段说明:** +- 返回服务器支持的特性列表 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取服务器支持的特性标志 + +--- + +### 2.10 获取提示信息 + +**接口路径:** `GET /prompt` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "exec_info": { + "queue_remaining": 5 + } +} +``` + +**字段说明:** +- exec_info:执行信息 + - queue_remaining:队列中剩余的任务数 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取当前队列状态信息 + +--- + +### 2.11 获取对象信息 + +**接口路径:** `GET /object_info` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "KSampler": { + "input": { + "required": { + "seed": ["INT", {"default": 0, "min": 0, "max": 18446744073709551615}], + "steps": ["INT", {"default": 20, "min": 1, "max": 1000}], + ... + }, + "optional": {...} + }, + "input_order": {...}, + "output": ["LATENT"], + "output_is_list": [false], + "output_name": ["LATENT"], + "name": "KSampler", + "display_name": "KSampler", + "description": "KSampler description", + "python_module": "nodes", + "category": "sampling", + "output_node": false + }, + ... +} +``` + +**字段说明:** +- 返回所有节点的信息字典 +- 每个节点包含: + - input:输入参数定义(required/optional) + - input_order:输入参数顺序 + - output:输出类型列表 + - output_is_list:输出是否为列表 + - output_name:输出名称 + - name:节点类名 + - display_name:显示名称 + - description:节点描述 + - python_module:Python 模块 + - category:节点分类 + - output_node:是否为输出节点 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取所有可用节点的详细信息 + +--- + +### 2.12 获取特定节点信息 + +**接口路径:** `GET /object_info/{node_class}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| node_class | string | 是 | 节点类名(路径参数) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "KSampler": { + "input": {...}, + "output": ["LATENT"], + ... + } +} +``` + +**字段说明:** +- 返回指定节点的详细信息(同 2.11) + +**错误码及错误信息:** +- 无(如果节点不存在,返回空对象) + +**说明:** +- 获取指定节点的详细信息 + +--- + +## 3. 文件上传接口 + +### 3.1 上传图片 + +**接口路径:** `POST /upload/image` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| image | file | 是 | 图片文件 | +| overwrite | string | 否 | 是否覆盖(true/false/1) | +| type | string | 否 | 上传类型(input/temp/output),默认 input | +| subfolder | string | 否 | 子文件夹路径 | + +**请求头要求:** +- Content-Type: multipart/form-data + +**响应数据结构:** +```json +{ + "name": "image.png", + "subfolder": "", + "type": "input" +} +``` + +**字段说明:** +- name:保存的文件名 +- subfolder:子文件夹路径 +- type:文件类型 + +**错误码及错误信息:** +- 400:文件无效或路径不合法 + +**说明:** +- 上传图片到指定文件夹 +- 自动处理文件名冲突(添加序号) +- 支持覆盖模式 + +--- + +### 3.2 上传 Mask + +**接口路径:** `POST /upload/mask` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| image | file | 是 | Mask 图片文件 | +| original_ref | string | 是 | 原始图片引用(JSON 字符串) | +| overwrite | string | 否 | 是否覆盖 | +| type | string | 否 | 上传类型 | +| subfolder | string | 否 | 子文件夹路径 | + +**请求头要求:** +- Content-Type: multipart/form-data + +**响应数据结构:** +```json +{ + "name": "mask.png", + "subfolder": "", + "type": "input" +} +``` + +**字段说明:** +- 同 3.1 + +**错误码及错误信息:** +- 400:参数无效或原始图片不存在 +- 403:访问权限不足 + +**说明:** +- 上传 mask 图片并将其应用到原始图片 +- original_ref 格式:`{"filename": "image.png", "type": "output", "subfolder": ""}` + +--- + +## 4. 任务管理接口 + +### 4.1 获取所有任务 + +**接口路径:** `GET /api/jobs` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| status | string | 否 | 状态过滤(逗号分隔):pending, in_progress, completed, failed | +| workflow_id | string | 否 | 工作流 ID 过滤 | +| sort_by | string | 否 | 排序字段(created_at/execution_duration),默认 created_at | +| sort_order | string | 否 | 排序方向(asc/desc),默认 desc | +| limit | integer | 否 | 返回数量限制(正整数) | +| offset | integer | 否 | 跳过数量(非负整数),默认 0 | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "jobs": [ + { + "id": "prompt_id_1", + "status": "completed", + "priority": 1, + "create_time": 1234567890000, + "execution_start_time": 1234567891000, + "execution_end_time": 1234567895000, + "outputs_count": 4, + "preview_output": { + "filename": "image.png", + "subfolder": "", + "type": "output", + "nodeId": "3", + "mediaType": "images" + }, + "workflow_id": "workflow_1" + } + ], + "pagination": { + "offset": 0, + "limit": 10, + "total": 100, + "has_more": true + } +} +``` + +**字段说明:** +- jobs:任务列表 + - id:任务 ID(prompt_id) + - status:任务状态(pending/in_progress/completed/failed) + - priority:优先级 + - create_time:创建时间(毫秒时间戳) + - execution_start_time:执行开始时间 + - execution_end_time:执行结束时间 + - execution_error:执行错误信息(失败时) + - outputs_count:输出数量 + - preview_output:预览输出 + - workflow_id:工作流 ID +- pagination:分页信息 + - offset:偏移量 + - limit:限制数量 + - total:总数 + - has_more:是否有更多 + +**错误码及错误信息:** +- 400:参数无效(状态值、排序字段等) + +**说明:** +- 获取所有任务列表,支持过滤、排序和分页 +- 包括运行中、排队、已完成和失败的任务 + +--- + +### 4.2 获取特定任务 + +**接口路径:** `GET /api/jobs/{job_id}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| job_id | string | 是 | 任务 ID(路径参数) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "id": "prompt_id_1", + "status": "completed", + "priority": 1, + "create_time": 1234567890000, + "execution_start_time": 1234567891000, + "execution_end_time": 1234567895000, + "outputs": { + "3": { + "images": [ + { + "filename": "image.png", + "subfolder": "", + "type": "output" + } + ] + } + }, + "execution_status": { + "status_str": "success", + "messages": [...] + }, + "workflow": { + "prompt": {...}, + "extra_data": {...} + }, + "outputs_count": 4, + "preview_output": {...}, + "workflow_id": "workflow_1" +} +``` + +**字段说明:** +- 同 4.1,额外包含: + - outputs:完整输出数据 + - execution_status:执行状态详情 + - workflow:工作流详情 + +**错误码及错误信息:** +- 400:job_id 参数缺失 +- 404:任务不存在 + +**说明:** +- 获取指定任务的详细信息,包括完整输出和工作流数据 + +--- + +### 4.3 获取历史记录 + +**接口路径:** `GET /history` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| max_items | integer | 否 | 最大返回数量 | +| offset | integer | 否 | 偏移量,默认 -1(返回所有) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "prompt_id_1": { + "prompt": [...], + "outputs": {...}, + "status": {...} + }, + ... +} +``` + +**字段说明:** +- 返回历史记录字典,key 为 prompt_id +- 每个记录包含: + - prompt:提示数据 + - outputs:输出数据 + - status:状态信息 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取已执行任务的历史记录 + +--- + +### 4.4 获取特定历史记录 + +**接口路径:** `GET /history/{prompt_id}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| prompt_id | string | 是 | 提示 ID(路径参数) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "prompt": [...], + "outputs": {...}, + "status": {...} +} +``` + +**字段说明:** +- 同 4.3 + +**错误码及错误信息:** +- 无(如果不存在返回空对象) + +**说明:** +- 获取指定提示 ID 的历史记录 + +--- + +### 4.5 获取队列 + +**接口路径:** `GET /queue` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "queue_running": [ + [1, "prompt_id_1", {...}, {...}, [...]] + ], + "queue_pending": [ + [2, "prompt_id_2", {...}, {...}, [...]], + [3, "prompt_id_3", {...}, {...}, [...]] + ] +} +``` + +**字段说明:** +- queue_running:正在运行的任务列表 +- queue_pending:等待中的任务列表 +- 每个任务是一个 5 元组: + - [0]:优先级(数字) + - [1]:prompt_id + - [2]:prompt 数据 + - [3]:extra_data + - [4]:outputs_to_execute + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取当前队列状态,包括运行中和等待中的任务 + +--- + +### 4.6 提交提示 + +**接口路径:** `POST /prompt` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| prompt | object | 是 | 工作流提示对象 | +| prompt_id | string | 否 | 提示 ID,默认自动生成 UUID | +| client_id | string | 否 | 客户端 ID | +| number | number | 否 | 任务编号 | +| front | boolean | 否 | 是否插入队列前面 | +| partial_execution_targets | array | 否 | 部分执行目标 | +| extra_data | object | 否 | 额外数据 | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +```json +{ + "prompt_id": "550a8d1c-1234-4567-89ab-123456789abc", + "number": 1, + "node_errors": {} +} +``` + +**字段说明:** +- prompt_id:生成的提示 ID +- number:任务编号 +- node_errors:节点错误信息 + +**错误码及错误信息:** +- 400:提示无效或验证失败 + - error:错误信息 + - node_errors:节点错误详情 + +**说明:** +- 提交工作流任务到队列 +- 自动验证提示的有效性 +- 返回 prompt_id 用于后续查询 + +--- + +### 4.7 队列操作 + +**接口路径:** `POST /queue` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| clear | boolean | 否 | 是否清空队列 | +| delete | array | 否 | 要删除的任务 ID 列表 | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +- HTTP 200 OK + +**错误码及错误信息:** +- 无 + +**说明:** +- 清空队列或删除指定任务 +- 可以同时执行清空和删除操作 + +--- + +### 4.8 中断任务 + +**接口路径:** `POST /interrupt` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| prompt_id | string | 否 | 要中断的任务 ID(可选) | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +- HTTP 200 OK + +**错误码及错误信息:** +- 无 + +**说明:** +- 中断正在运行的任务 +- 如果提供 prompt_id,只中断指定任务 +- 如果不提供,中断所有正在运行的任务 + +--- + +### 4.9 释放资源 + +**接口路径:** `POST /free` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| unload_models | boolean | 否 | 是否卸载模型 | +| free_memory | boolean | 否 | 是否释放内存 | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +- HTTP 200 OK + +**错误码及错误信息:** +- 无 + +**说明:** +- 释放系统资源 +- 可以卸载模型和/或释放内存 + +--- + +### 4.10 历史记录操作 + +**接口路径:** `POST /history` + +**HTTP 方法:** POST + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| clear | boolean | 否 | 是否清空历史记录 | +| delete | array | 否 | 要删除的历史记录 ID 列表 | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +- HTTP 200 OK + +**错误码及错误信息:** +- 无 + +**说明:** +- 清空历史记录或删除指定历史记录 +- 可以同时执行清空和删除操作 + +--- + +## 5. 内部接口 + +### 5.1 获取日志 + +**接口路径:** `GET /internal/logs` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +"2024-01-01 12:00:00 - Log message 1\n2024-01-01 12:01:00 - Log message 2\n..." +``` + +**字段说明:** +- 返回格式化的日志字符串 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取系统日志(内部接口,仅供前端使用) + +--- + +### 5.2 获取原始日志 + +**接口路径:** `GET /internal/logs/raw` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "entries": [ + {"t": "2024-01-01 12:00:00", "m": "Log message 1"}, + {"t": "2024-01-01 12:01:00", "m": "Log message 2"} + ], + "size": { + "cols": 80, + "rows": 24 + } +} +``` + +**字段说明:** +- entries:日志条目数组 + - t:时间戳 + - m:消息内容 +- size:终端大小 + - cols:列数 + - rows:行数 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取原始日志数据(内部接口) + +--- + +### 5.3 订阅日志 + +**接口路径:** `PATCH /internal/logs/subscribe` + +**HTTP 方法:** PATCH + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| clientId | string | 是 | 客户端 ID | +| enabled | boolean | 是 | 是否启用订阅 | + +**请求头要求:** +- Content-Type: application/json + +**响应数据结构:** +- HTTP 200 OK + +**错误码及错误信息:** +- 无 + +**说明:** +- 订阅或取消订阅日志推送(内部接口) + +--- + +### 5.4 获取文件夹路径 + +**接口路径:** `GET /internal/folder_paths` + +**HTTP 方法:** GET + +**请求参数:** 无 + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +{ + "checkpoints": "path/to/checkpoints", + "loras": "path/to/loras", + "embeddings": "path/to/embeddings", + ... +} +``` + +**字段说明:** +- 返回所有文件夹类型的路径 + +**错误码及错误信息:** +- 无 + +**说明:** +- 获取所有文件夹路径(内部接口) + +--- + +### 5.5 获取文件列表 + +**接口路径:** `GET /internal/files/{directory_type}` + +**HTTP 方法:** GET + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| directory_type | string | 是 | 目录类型(output/input/temp) | + +**请求头要求:** 无特殊要求 + +**响应数据结构:** +```json +[ + "file1.png", + "file2.png", + ... +] +``` + +**字段说明:** +- 返回指定目录中的文件列表(按修改时间倒序) + +**错误码及错误信息:** +- 400:无效的目录类型 + +**说明:** +- 获取指定目录中的文件列表(内部接口) + +--- + +## 6. 任务提交与结果获取流程 + +### 6.1 流程接口概述 + +ComfyUI 的任务提交与结果获取流程涉及以下核心接口: + +1. **提交任务接口**:`POST /prompt` - 提交工作流任务 +2. **查询任务接口**:`GET /api/jobs/{job_id}` - 查询任务状态和结果 +3. **队列查询接口**:`GET /queue` - 查询队列状态 +4. **历史记录接口**:`GET /history/{prompt_id}` - 获取已完成任务的详细信息 +5. **文件查看接口**:`GET /view` - 查看生成的图片文件 + +**接口关联方式:** + +- 提交任务后,`POST /prompt` 返回 `prompt_id` +- 使用 `prompt_id` 通过 `GET /api/jobs/{job_id}` 查询任务状态 +- 任务完成后,通过 `GET /history/{prompt_id}` 获取完整结果 +- 使用返回的文件信息通过 `GET /view` 下载或查看生成的图片 + +--- + +### 6.2 任务状态说明 + +任务状态(status)有以下四种: + +| 状态值 | 英文 | 说明 | +|--------|------|------| +| pending | 待执行 | 任务在队列中等待执行 | +| in_progress | 执行中 | 任务正在执行 | +| completed | 已完成 | 任务执行成功 | +| failed | 失败 | 任务执行失败 | + +**状态转换流程:** + +``` +pending → in_progress → completed + ↓ + failed +``` + +**状态判断逻辑:** + +- **pending**:任务在 `queue_pending` 列表中 +- **in_progress**:任务在 `queue_running` 列表中 +- **completed**:任务在历史记录中,且 `status.status_str == "success"` +- **failed**:任务在历史记录中,且 `status.status_str == "error"` + +--- + +### 6.3 完整任务生命周期调用示例 + +#### 步骤 1:提交任务 + +**请求:** +```bash +POST /prompt +Content-Type: application/json + +{ + "prompt": { + "1": { + "class_type": "CheckpointLoaderSimple", + "inputs": { + "ckpt_name": "v1-5-pruned-emaonly.safetensors" + } + }, + "2": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "a beautiful landscape", + "clip": ["1", 1] + } + }, + "3": { + "class_type": "KSampler", + "inputs": { + "seed": 123456789, + "steps": 20, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["1", 0], + "positive": ["2", 0], + "negative": ["2", 0], + "latent_image": ["4", 0] + } + }, + "4": { + "class_type": "EmptyLatentImage", + "inputs": { + "width": 512, + "height": 512, + "batch_size": 1 + } + }, + "5": { + "class_type": "VAEDecode", + "inputs": { + "samples": ["3", 0], + "vae": ["1", 2] + } + }, + "6": { + "class_type": "SaveImage", + "inputs": { + "filename_prefix": "ComfyUI", + "images": ["5", 0] + } + } + }, + "client_id": "client_123" +} +``` + +**响应:** +```json +{ + "prompt_id": "550a8d1c-1234-4567-89ab-123456789abc", + "number": 1, + "node_errors": {} +} +``` + +--- + +#### 步骤 2:查询任务状态 + +**请求:** +```bash +GET /api/jobs/550a8d1c-1234-4567-89ab-123456789abc +``` + +**响应(执行中):** +```json +{ + "id": "550a8d1c-1234-4567-89ab-123456789abc", + "status": "in_progress", + "priority": 1, + "create_time": 1234567890000, + "outputs_count": 0, + "workflow_id": null +} +``` + +**响应(已完成):** +```json +{ + "id": "550a8d1c-1234-4567-89ab-123456789abc", + "status": "completed", + "priority": 1, + "create_time": 1234567890000, + "execution_start_time": 1234567891000, + "execution_end_time": 1234567895000, + "outputs": { + "6": { + "images": [ + { + "filename": "ComfyUI_00001.png", + "subfolder": "", + "type": "output" + } + ] + } + }, + "execution_status": { + "status_str": "success", + "messages": [ + ["execution_start", {"timestamp": 1234567891000}], + ["execution_success", {"timestamp": 1234567895000}] + ] + }, + "workflow": { + "prompt": {...}, + "extra_data": {...} + }, + "outputs_count": 1, + "preview_output": { + "filename": "ComfyUI_00001.png", + "subfolder": "", + "type": "output", + "nodeId": "6", + "mediaType": "images" + }, + "workflow_id": null +} +``` + +**响应(失败):** +```json +{ + "id": "550a8d1c-1234-4567-89ab-123456789abc", + "status": "failed", + "priority": 1, + "create_time": 1234567890000, + "execution_start_time": 1234567891000, + "execution_end_time": 1234567892000, + "execution_error": { + "node_id": "3", + "exception_type": "RuntimeError", + "exception_message": "CUDA out of memory", + "traceback": "..." + }, + "outputs_count": 0, + "workflow_id": null +} +``` + +--- + +#### 步骤 3:查看生成的图片 + +**请求:** +```bash +GET /view?filename=ComfyUI_00001.png&type=output&subfolder= +``` + +**响应:** +- 返回图片文件(PNG 格式) + +**请求(带预览参数):** +```bash +GET /view?filename=ComfyUI_00001.png&type=output&preview=webp;90 +``` + +**响应:** +- 返回 WebP 格式的预览图片(质量 90) + +--- + +#### 步骤 4:查询历史记录 + +**请求:** +```bash +GET /history/550a8d1c-1234-4567-89ab-123456789abc +``` + +**响应:** +```json +{ + "prompt": [ + 1, + "550a8d1c-1234-4567-89ab-123456789abc", + { + "1": { + "class_type": "CheckpointLoaderSimple", + "inputs": {...} + }, + ... + }, + { + "client_id": "client_123", + "create_time": 1234567890000 + }, + [...] + ], + "outputs": { + "6": { + "images": [ + { + "filename": "ComfyUI_00001.png", + "subfolder": "", + "type": "output" + } + ] + } + }, + "status": { + "status_str": "success", + "messages": [...] + } +} +``` + +--- + +#### 步骤 5:查询队列状态(可选) + +**请求:** +```bash +GET /queue +``` + +**响应:** +```json +{ + "queue_running": [ + [1, "550a8d1c-1234-4567-89ab-123456789abc", {...}, {...}, [...]] + ], + "queue_pending": [ + [2, "another_prompt_id", {...}, {...}, [...]] + ] +} +``` + +--- + +### 完整流程伪代码示例 + +```python +import requests +import time + +# 1. 提交任务 +def submit_task(workflow): + response = requests.post( + "http://localhost:8188/prompt", + json={"prompt": workflow, "client_id": "my_client"} + ) + return response.json()["prompt_id"] + +# 2. 轮询任务状态 +def wait_for_completion(prompt_id, poll_interval=1, timeout=300): + start_time = time.time() + while time.time() - start_time < timeout: + response = requests.get(f"http://localhost:8188/api/jobs/{prompt_id}") + job = response.json() + + if job["status"] == "completed": + return job + elif job["status"] == "failed": + raise Exception(f"Task failed: {job.get('execution_error')}") + + time.sleep(poll_interval) + + raise TimeoutError("Task timed out") + +# 3. 获取生成的图片 +def get_image(filename, subfolder=""): + response = requests.get( + "http://localhost:8188/view", + params={ + "filename": filename, + "type": "output", + "subfolder": subfolder + } + ) + return response.content + +# 完整流程 +workflow = { + "1": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"} + }, + # ... 其他节点 +} + +# 提交任务 +prompt_id = submit_task(workflow) +print(f"Task submitted: {prompt_id}") + +# 等待完成 +job = wait_for_completion(prompt_id) +print(f"Task completed: {job}") + +# 获取图片 +for node_id, outputs in job["outputs"].items(): + for image in outputs.get("images", []): + image_data = get_image(image["filename"], image["subfolder"]) + with open(image["filename"], "wb") as f: + f.write(image_data) + print(f"Saved: {image['filename']}") +``` + +--- + +### WebSocket 实时监听(可选) + +除了轮询,还可以使用 WebSocket 实时接收任务状态更新: + +```javascript +const ws = new WebSocket('ws://localhost:8188/ws?clientId=my_client'); + +ws.onopen = () => { + console.log('WebSocket connected'); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'status') { + console.log('Queue status:', data.data); + } else if (data.type === 'executing') { + console.log('Executing node:', data.data.node); + } else if (data.type === 'progress') { + console.log('Progress:', data.data); + } else if (data.type === 'execution_success') { + console.log('Execution successful:', data.data); + } else if (data.type === 'execution_error') { + console.error('Execution error:', data.data); + } +}; +``` + +--- + +## 附录 + +### API 前缀说明 + +ComfyUI 支持两种 API 路径格式: + +1. **无前缀**:`/prompt`, `/queue`, `/history` 等 +2. **带前缀**:`/api/prompt`, `/api/queue`, `/api/history` 等 + +两种格式都可以使用,建议使用带 `/api` 前缀的格式以便于代理和转发。 + +### 错误处理 + +所有接口在发生错误时都会返回相应的 HTTP 状态码和错误信息: + +- **400**:请求参数错误 +- **403**:访问权限不足 +- **404**:资源不存在 +- **500**:服务器内部错误 + +错误响应格式: +```json +{ + "error": "Error message", + "node_errors": {} +} +``` + +### 认证 + +ComfyUI 默认不启用认证。如果需要启用认证,请参考相关配置文档。 + +### 速率限制 + +ComfyUI 默认不实施速率限制。在生产环境中,建议通过反向代理(如 Nginx)实施速率限制。 + +--- + +**文档版本:** 1.0 +**最后更新:** 2026-03-12 +**ComfyUI 版本:** 0.2.0+ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c627dc4 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..8e496a3 --- /dev/null +++ b/backend/.env @@ -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 diff --git a/backend/config/servers.json b/backend/config/servers.json new file mode 100644 index 0000000..c8519c7 --- /dev/null +++ b/backend/config/servers.json @@ -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" + } +} diff --git a/backend/docs/任务队列集成提示词.md b/backend/docs/任务队列集成提示词.md new file mode 100644 index 0000000..877a2fc --- /dev/null +++ b/backend/docs/任务队列集成提示词.md @@ -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. **幂等性**:考虑实现回调接口的幂等性,防止重复处理 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6d36994 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..b681aad --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,1435 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.6.2 + version: 1.13.6 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.3.1 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + form-data: + specifier: ^4.0.0 + version: 4.0.5 + ioredis: + specifier: ^5.3.2 + version: 5.10.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + morgan: + specifier: ^1.10.0 + version: 1.10.1 + multer: + specifier: ^1.4.5-lts.1 + version: 1.4.5-lts.2 + uuid: + specifier: ^9.0.1 + version: 9.0.1 + winston: + specifier: ^3.11.0 + version: 3.19.0 + ws: + specifier: ^8.14.2 + version: 8.19.0 + devDependencies: + nodemon: + specifier: ^3.0.2 + version: 3.1.14 + +packages: + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ioredis@5.10.0: + resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@1.4.5-lts.2: + resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} + engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + +snapshots: + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@ioredis/commands@1.5.1': {} + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@types/triple-beam@1.3.5': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + append-field@1.0.0: {} + + array-flatten@1.1.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@4.0.4: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + binary-extensions@2.3.0: {} + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cluster-key-slot@1.1.2: {} + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fecha@4.2.3: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + fn.name@1.1.0: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + inherits@2.0.4: {} + + ioredis@5.10.0: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-stream@2.0.1: {} + + isarray@1.0.0: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kuler@2.0.0: {} + + lodash.defaults@4.2.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@1.4.5-lts.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + negotiator@0.6.3: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.4 + pstree.remy: 1.1.8 + semver: 7.7.4 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + picomatch@2.3.1: {} + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pstree.remy@1.1.8: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + + stack-trace@0.0.10: {} + + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + text-hex@1.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + triple-beam@1.4.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + undefsafe@2.0.5: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@9.0.1: {} + + vary@1.1.2: {} + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + ws@8.19.0: {} + + xtend@4.0.2: {} diff --git a/backend/src/cluster-manager/index.js b/backend/src/cluster-manager/index.js new file mode 100644 index 0000000..0f9c025 --- /dev/null +++ b/backend/src/cluster-manager/index.js @@ -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} 实例状态 + */ + 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} 检查后的实例列表 + */ + 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} 检查后的实例列表 + */ + 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(); diff --git a/backend/src/comfyui-monitor/index.js b/backend/src/comfyui-monitor/index.js new file mode 100644 index 0000000..64b1f07 --- /dev/null +++ b/backend/src/comfyui-monitor/index.js @@ -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(); diff --git a/backend/src/config/index.js b/backend/src/config/index.js new file mode 100644 index 0000000..6a5283a --- /dev/null +++ b/backend/src/config/index.js @@ -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(); diff --git a/backend/src/data-sync/index.js b/backend/src/data-sync/index.js new file mode 100644 index 0000000..3344817 --- /dev/null +++ b/backend/src/data-sync/index.js @@ -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(); diff --git a/backend/src/file-uploader/index.js b/backend/src/file-uploader/index.js new file mode 100644 index 0000000..caa1582 --- /dev/null +++ b/backend/src/file-uploader/index.js @@ -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(); diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..bb63d7a --- /dev/null +++ b/backend/src/index.js @@ -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(); +}); diff --git a/backend/src/json-persistence/index.js b/backend/src/json-persistence/index.js new file mode 100644 index 0000000..3bc6295 --- /dev/null +++ b/backend/src/json-persistence/index.js @@ -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(); diff --git a/backend/src/logger/index.js b/backend/src/logger/index.js new file mode 100644 index 0000000..f9fc7df --- /dev/null +++ b/backend/src/logger/index.js @@ -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; diff --git a/backend/src/redis-manager/index.js b/backend/src/redis-manager/index.js new file mode 100644 index 0000000..4ebcac8 --- /dev/null +++ b/backend/src/redis-manager/index.js @@ -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(); diff --git a/backend/src/task-forwarder/index.js b/backend/src/task-forwarder/index.js new file mode 100644 index 0000000..bb4d85f --- /dev/null +++ b/backend/src/task-forwarder/index.js @@ -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(); diff --git a/backend/src/task-queue-client/index.js b/backend/src/task-queue-client/index.js new file mode 100644 index 0000000..faaa966 --- /dev/null +++ b/backend/src/task-queue-client/index.js @@ -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(); diff --git a/backend/src/websocket-client/index.js b/backend/src/websocket-client/index.js new file mode 100644 index 0000000..bff131c --- /dev/null +++ b/backend/src/websocket-client/index.js @@ -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连接 + */ + 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(); diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..93915fc --- /dev/null +++ b/frontend/.env @@ -0,0 +1,3 @@ +# 默认环境配置 +VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api +VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..93915fc --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +# 默认环境配置 +VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api +VITE_MESSAGE_DISPATCHER_BASE_URL=http://localhost:4000 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..0a20ee7 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,2 @@ +# 生产环境配置 +VITE_API_BASE_URL=https://a6848e23804d4315b56a48b456ee83ab.pvt.hz.smartml.cn/api diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d4090c9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + ComfyUI Cluster Bridge - 管理后台 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..07b514f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..d1ac0b8 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1404 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.2(vue@3.5.30) + axios: + specifier: ^1.6.2 + version: 1.13.6 + echarts: + specifier: ^5.4.3 + version: 5.6.0 + element-plus: + specifier: ^2.4.4 + version: 2.13.5(vue@3.5.30) + pinia: + specifier: ^2.1.7 + version: 2.3.1(vue@3.5.30) + vue: + specifier: ^3.3.11 + version: 3.5.30 + vue-router: + specifier: ^4.2.5 + version: 4.6.4(vue@3.5.30) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^4.5.2 + version: 4.6.2(vite@5.4.21(sass@1.98.0))(vue@3.5.30) + sass: + specifier: ^1.69.5 + version: 1.98.0 + vite: + specifier: ^5.0.8 + version: 5.4.21(sass@1.98.0) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@vitejs/plugin-vue@4.6.2': + resolution: {integrity: sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vueuse/core@12.0.0': + resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==} + + '@vueuse/metadata@12.0.0': + resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==} + + '@vueuse/shared@12.0.0': + resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + element-plus@2.13.5: + resolution: {integrity: sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==} + peerDependencies: + vue: ^3.3.0 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sass@1.98.0: + resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} + engines: {node: '>=14.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.30)': + dependencies: + vue: 3.5.30 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/web-bluetooth@0.0.20': {} + + '@vitejs/plugin-vue@4.6.2(vite@5.4.21(sass@1.98.0))(vue@3.5.30)': + dependencies: + vite: 5.4.21(sass@1.98.0) + vue: 3.5.30 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30)': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30 + + '@vue/shared@3.5.30': {} + + '@vueuse/core@12.0.0': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 12.0.0 + '@vueuse/shared': 12.0.0 + vue: 3.5.30 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.0.0': {} + + '@vueuse/shared@12.0.0': + dependencies: + vue: 3.5.30 + transitivePeerDependencies: + - typescript + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.2.3: {} + + dayjs@1.11.19: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: + optional: true + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + element-plus@2.13.5(vue@3.5.30): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.30) + '@floating-ui/dom': 1.7.6 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 12.0.0 + async-validator: 4.2.5 + dayjs: 1.11.19 + lodash: 4.17.23 + lodash-es: 4.17.23 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.23)(lodash@4.17.23) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.30 + transitivePeerDependencies: + - typescript + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + immutable@5.1.5: {} + + is-extglob@2.1.1: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + lodash-es@4.17.23: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.23)(lodash@4.17.23): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.17.23 + lodash-es: 4.17.23 + + lodash@4.17.23: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + normalize-wheel-es@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: + optional: true + + pinia@2.3.1(vue@3.5.30): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30 + vue-demi: 0.14.10(vue@3.5.30) + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + readdirp@4.1.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + sass@1.98.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + source-map-js@1.2.1: {} + + tslib@2.3.0: {} + + vite@5.4.21(sass@1.98.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + fsevents: 2.3.3 + sass: 1.98.0 + + vue-demi@0.14.10(vue@3.5.30): + dependencies: + vue: 3.5.30 + + vue-router@4.6.4(vue@3.5.30): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30 + + vue@3.5.30: + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30) + '@vue/shared': 3.5.30 + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..52454f7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..a8fbcdd --- /dev/null +++ b/frontend/src/api/index.js @@ -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 已废弃')) +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..a0ac009 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..89668e2 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..5d5bb23 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/config.js b/frontend/src/stores/config.js new file mode 100644 index 0000000..a29f151 --- /dev/null +++ b/frontend/src/stores/config.js @@ -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 + } +}) diff --git a/frontend/src/stores/instance.js b/frontend/src/stores/instance.js new file mode 100644 index 0000000..384765d --- /dev/null +++ b/frontend/src/stores/instance.js @@ -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 + } +}) diff --git a/frontend/src/stores/task.js b/frontend/src/stores/task.js new file mode 100644 index 0000000..448eb47 --- /dev/null +++ b/frontend/src/stores/task.js @@ -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 + } +}) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..0fb6a7f --- /dev/null +++ b/frontend/src/stores/user.js @@ -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 + } +}) diff --git a/frontend/src/styles/design-system.scss b/frontend/src/styles/design-system.scss new file mode 100644 index 0000000..1fe6589 --- /dev/null +++ b/frontend/src/styles/design-system.scss @@ -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; +} + diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..899c9cb --- /dev/null +++ b/frontend/src/styles/index.scss @@ -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; +} + diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..c34b716 --- /dev/null +++ b/frontend/src/utils/request.js @@ -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 diff --git a/frontend/src/views/Config.vue b/frontend/src/views/Config.vue new file mode 100644 index 0000000..7887884 --- /dev/null +++ b/frontend/src/views/Config.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/frontend/src/views/Instances.vue b/frontend/src/views/Instances.vue new file mode 100644 index 0000000..9395d2f --- /dev/null +++ b/frontend/src/views/Instances.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..9024cd9 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/frontend/src/views/Monitor.vue b/frontend/src/views/Monitor.vue new file mode 100644 index 0000000..c54ecc0 --- /dev/null +++ b/frontend/src/views/Monitor.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/frontend/src/views/Tasks.vue b/frontend/src/views/Tasks.vue new file mode 100644 index 0000000..06d8871 --- /dev/null +++ b/frontend/src/views/Tasks.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..264b070 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } + } +}) diff --git a/message-dispatcher/.env b/message-dispatcher/.env new file mode 100644 index 0000000..dbf6942 --- /dev/null +++ b/message-dispatcher/.env @@ -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 diff --git a/message-dispatcher/package.json b/message-dispatcher/package.json new file mode 100644 index 0000000..ff837f0 --- /dev/null +++ b/message-dispatcher/package.json @@ -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" + } +} diff --git a/message-dispatcher/src/api/index.js b/message-dispatcher/src/api/index.js new file mode 100644 index 0000000..0ac88eb --- /dev/null +++ b/message-dispatcher/src/api/index.js @@ -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; diff --git a/message-dispatcher/src/auth/index.js b/message-dispatcher/src/auth/index.js new file mode 100644 index 0000000..b5b1894 --- /dev/null +++ b/message-dispatcher/src/auth/index.js @@ -0,0 +1,4 @@ +import authRoutes from './routes.js'; + +export { authRoutes }; +export default authRoutes; diff --git a/message-dispatcher/src/auth/jwt.js b/message-dispatcher/src/auth/jwt.js new file mode 100644 index 0000000..eb96f99 --- /dev/null +++ b/message-dispatcher/src/auth/jwt.js @@ -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 +}; diff --git a/message-dispatcher/src/auth/middleware.js b/message-dispatcher/src/auth/middleware.js new file mode 100644 index 0000000..256098d --- /dev/null +++ b/message-dispatcher/src/auth/middleware.js @@ -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 +}; diff --git a/message-dispatcher/src/auth/password.js b/message-dispatcher/src/auth/password.js new file mode 100644 index 0000000..709dee5 --- /dev/null +++ b/message-dispatcher/src/auth/password.js @@ -0,0 +1,5 @@ +import bcryptjs from 'bcryptjs'; + +export async function verifyPassword(password, hashedPassword) { + return await bcryptjs.compare(password, hashedPassword); +} diff --git a/message-dispatcher/src/auth/rate-limit.js b/message-dispatcher/src/auth/rate-limit.js new file mode 100644 index 0000000..3f144c7 --- /dev/null +++ b/message-dispatcher/src/auth/rate-limit.js @@ -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 +}; diff --git a/message-dispatcher/src/auth/routes.js b/message-dispatcher/src/auth/routes.js new file mode 100644 index 0000000..b95e029 --- /dev/null +++ b/message-dispatcher/src/auth/routes.js @@ -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; diff --git a/message-dispatcher/src/bridge-manager/index.js b/message-dispatcher/src/bridge-manager/index.js new file mode 100644 index 0000000..aa915d3 --- /dev/null +++ b/message-dispatcher/src/bridge-manager/index.js @@ -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(); diff --git a/message-dispatcher/src/index.js b/message-dispatcher/src/index.js new file mode 100644 index 0000000..347f371 --- /dev/null +++ b/message-dispatcher/src/index.js @@ -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); diff --git a/message-dispatcher/src/logger/index.js b/message-dispatcher/src/logger/index.js new file mode 100644 index 0000000..94b7cf6 --- /dev/null +++ b/message-dispatcher/src/logger/index.js @@ -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; diff --git a/message-dispatcher/src/websocket-server/index.js b/message-dispatcher/src/websocket-server/index.js new file mode 100644 index 0000000..d1f058e --- /dev/null +++ b/message-dispatcher/src/websocket-server/index.js @@ -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(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..be045f5 --- /dev/null +++ b/package.json @@ -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" +}