From a8faa7dcaab8df38c13beaf0cbae771323af43d6 Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Thu, 15 Jan 2026 14:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84=E5=B9=B6?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构Redis心跳数据结构,使用统一的项目列表键 - 新增数据迁移工具和API端点 - 更新前端以使用真实项目数据 - 添加系统部署配置和文档 - 修复代码格式和样式问题 --- .env.example | 4 - .../BLS Project Console 系统功能优化与修复.md | 29 +--- README.md | 6 +- deploy/docker/docker-compose.backend.yml | 21 +++ deploy/nginx/conf.d/bls_project_console.conf | 47 +++++++ deploy/systemd/bls-project-console.service | 23 +++ docs/redis-data-structure.md | 32 +---- docs/redis-integration-protocol.md | 22 ++- .../refactor-project-heartbeat/proposal.md | 12 +- .../specs/redis-connection/spec.md | 11 +- .../refactor-project-heartbeat/tasks.md | 8 +- openspec/specs/command/spec.md | 17 --- openspec/specs/logging/spec.md | 13 -- openspec/specs/redis-connection/spec.md | 17 --- src/backend/routes/commands.js | 40 ++---- src/backend/routes/logs.js | 98 +++++-------- src/backend/routes/projects.js | 22 +-- src/backend/server.js | 20 ++- src/backend/services/migrateHeartbeatData.js | 133 +++++------------- src/frontend/App.vue | 38 ++--- src/frontend/components/Console.vue | 110 ++++----------- src/frontend/store/projectStore.js | 2 +- src/frontend/views/CommandView.vue | 70 ++------- src/frontend/views/LogView.vue | 72 +++------- 24 files changed, 307 insertions(+), 560 deletions(-) create mode 100644 deploy/docker/docker-compose.backend.yml create mode 100644 deploy/nginx/conf.d/bls_project_console.conf create mode 100644 deploy/systemd/bls-project-console.service diff --git a/.env.example b/.env.example index fcd9aff..85bddb1 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,6 @@ REDIS_PASSWORD= # Redis password (leave empty if not set) REDIS_DB=0 # Redis database number (default: 0) REDIS_CONNECT_TIMEOUT_MS=2000 # Connection timeout in ms (default: 2000) -# Application -# Optional: port for backend server. Current default in code is 3001 but you can override it here. -PORT=3001 - # Command control (HTTP) # Timeout for calling target project APIs (ms) COMMAND_API_TIMEOUT_MS=5000 diff --git a/.trae/documents/BLS Project Console 系统功能优化与修复.md b/.trae/documents/BLS Project Console 系统功能优化与修复.md index 7bea580..e3c8bcd 100644 --- a/.trae/documents/BLS Project Console 系统功能优化与修复.md +++ b/.trae/documents/BLS Project Console 系统功能优化与修复.md @@ -1,31 +1,26 @@ # BLS Project Console 系统功能优化与修复计划 ## 📋 任务概述 - 完成界面显示修复、Redis数据结构重构、相关代码修改、文档更新和测试验证。 ## 🔍 当前问题分析 ### 1. 界面显示问题 - - **ProjectSelector.vue** (第54-97行) 包含硬编码的6个测试假数据 - 没有从Redis读取真实项目心跳数据 - 界面展示的是静态测试数据,而非真实有效的项目 ### 2. Redis数据结构问题 - - 当前使用分散的键:`{projectName}_项目心跳` - 每个项目的心跳数据独立存储,难以统一管理 - 缺少统一的项目列表查询机制 ### 3. 后端API现状 - - **logs.js** 和 **commands.js** 都从 `{projectName}_项目心跳` 读取数据 - **redisKeys.js** 定义了 `projectHeartbeatKey(projectName)` 函数 - 缺少获取所有项目列表的API端点 ### 4. 测试缺失 - - 项目中没有测试文件 - 缺少单元测试和集成测试 @@ -34,124 +29,104 @@ ### 阶段1:Redis数据结构重构 #### 1.1 新增Redis键定义 - - 在 `redisKeys.js` 中添加 `projectsListKey()` 函数 - 定义统一的项目列表键:`项目心跳` #### 1.2 创建数据迁移工具 - - 创建 `src/backend/services/migrateHeartbeatData.js` - 实现从分散键到统一列表的迁移逻辑 - 确保数据完整性和一致性 #### 1.3 更新心跳数据结构 - - 新结构:`[{ "apiBaseUrl": "...", "lastActiveAt": 1234567890, "projectName": "..." }]` - 每个对象包含:API地址、最新心跳时间、项目名称 ### 阶段2:后端API开发 #### 2.1 创建项目列表API - - 在 `src/backend/routes/` 下创建 `projects.js` - 实现 `GET /api/projects` 端点 - 从Redis读取项目列表并返回 #### 2.2 更新现有API - - 修改 `logs.js` 以兼容新的数据结构 - 修改 `commands.js` 以兼容新的数据结构 - 保持向后兼容性 #### 2.3 添加数据迁移端点 - - 实现 `POST /api/projects/migrate` 端点 - 支持手动触发数据迁移 ### 阶段3:前端代码修改 #### 3.1 更新ProjectSelector组件 - - 移除硬编码的测试数据(第54-97行) - 从 `/api/projects` 获取真实项目列表 - 实现数据加载状态和错误处理 #### 3.2 更新projectStore - - 扩展状态管理以支持项目列表 - 添加项目列表的响应式状态 #### 3.3 更新LogView和CommandView - - 连接到真实API端点 - 实现日志和命令的真实功能 - 添加错误处理和用户反馈 #### 3.4 更新App.vue - - 修正健康检查端点(从3000改为3001) - 确保服务状态监控正常工作 ### 阶段4:文档更新 #### 4.1 更新技术文档 - - 创建或更新 `docs/redis-data-structure.md` - 详细说明新的Redis数据结构设计 - 提供数据迁移指南 #### 4.2 更新OpenSpec规范 - - 更新 `openspec/specs/logging/spec.md` - 更新 `openspec/specs/command/spec.md` - 更新 `openspec/specs/redis-connection/spec.md` - 添加项目列表相关需求 #### 4.3 创建OpenSpec变更提案 - - 创建 `openspec/changes/refactor-project-heartbeat/` - 编写 `proposal.md`、`tasks.md` 和规范增量 ### 阶段5:测试开发 #### 5.1 单元测试 - - 创建 `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 测试数据迁移功能 - 测试Redis键操作 #### 5.2 集成测试 - - 创建 `src/backend/routes/__tests__/projects.test.js` - 测试项目列表API - 测试数据迁移API #### 5.3 前端测试 - - 创建 `src/frontend/components/__tests__/ProjectSelector.test.js` - 测试组件渲染和交互 #### 5.4 性能测试 - - 测试不同项目数量下的性能 - 测试不同心跳频率下的性能 ### 阶段6:代码质量与验证 #### 6.1 代码规范检查 - - 运行 ESLint 检查 - 运行 Prettier 格式化 - 确保符合项目代码规范 #### 6.2 功能验证 - - 验证项目选择功能正常 - 验证日志读取功能正常 - 验证命令发送功能正常 #### 6.3 性能验证 - - 测试大量项目场景 - 测试高频心跳场景 - 优化性能瓶颈 @@ -159,7 +134,6 @@ ## 📁 文件修改清单 ### 新增文件 - - `src/backend/services/migrateHeartbeatData.js` - 数据迁移工具 - `src/backend/routes/projects.js` - 项目列表API - `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 迁移测试 @@ -171,7 +145,6 @@ - `openspec/changes/refactor-project-heartbeat/specs/` - 规范增量 ### 修改文件 - - `src/backend/services/redisKeys.js` - 添加项目列表键 - `src/backend/routes/logs.js` - 更新心跳读取逻辑 - `src/backend/routes/commands.js` - 更新心跳读取逻辑 @@ -208,4 +181,4 @@ - 统一Redis数据结构,提高可维护性 - 完善测试覆盖,提高代码质量 - 更新文档和规范,保持一致性 -- 提供完整的变更记录和测试报告 +- 提供完整的变更记录和测试报告 \ No newline at end of file diff --git a/README.md b/README.md index f200244..c1bd26f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ npm install REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -REDIS_DB=0 +REDIS_DB=15 ``` ### 运行项目 @@ -257,7 +257,7 @@ npm run format REDIS_HOST=redis-server REDIS_PORT=6379 REDIS_PASSWORD=your-redis-password -REDIS_DB=0 +REDIS_DB=15 # 服务器配置 PORT=3001 @@ -317,7 +317,7 @@ docker run -p 3001:3001 --env-file .env bls-project-console | 主机名 | REDIS_HOST | localhost | Redis服务器主机名 | | 端口 | REDIS_PORT | 6379 | Redis服务器端口 | | 密码 | REDIS_PASSWORD | | Redis服务器密码 | -| 数据库索引 | REDIS_DB | 0 | Redis数据库索引 | +| 数据库索引 | REDIS_DB | 15 | Redis数据库索引 | | 连接超时 | REDIS_CONNECT_TIMEOUT | 10000 | 连接超时时间(毫秒) | | 最大重试次数 | REDIS_MAX_RETRIES | 3 | 最大重试次数 | | 重连策略 | REDIS_RECONNECT_STRATEGY | exponential | 重连策略(exponential/fixed) | diff --git a/deploy/docker/docker-compose.backend.yml b/deploy/docker/docker-compose.backend.yml new file mode 100644 index 0000000..dca8267 --- /dev/null +++ b/deploy/docker/docker-compose.backend.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + app: + image: node:22-alpine + container_name: bls-project-console-app + working_dir: /app + volumes: + - /vol1/1000/Docker/nginx/project/bls/bls_project_console:/app + environment: + NODE_ENV: production + REDIS_HOST: 10.8.8.109 + REDIS_PORT: 6379 + REDIS_PASSWORD: "" + REDIS_DB: 0 + HEARTBEAT_OFFLINE_THRESHOLD_MS: 10000 + COMMAND_API_TIMEOUT_MS: 5000 + command: ["node", "src/backend/server.js"] + restart: unless-stopped + ports: + - "3001:3001" diff --git a/deploy/nginx/conf.d/bls_project_console.conf b/deploy/nginx/conf.d/bls_project_console.conf new file mode 100644 index 0000000..2c0b92a --- /dev/null +++ b/deploy/nginx/conf.d/bls_project_console.conf @@ -0,0 +1,47 @@ +# BLS Project Console - Nginx site (for nginx-in-docker) +# +# Place this file on SERVER: +# /vol1/1000/Docker/nginx/conf.d/bls_project_console.conf +# +# Assumptions (based on your Nginx project layout): +# - Host path /vol1/1000/Docker/nginx/sites is mounted into container as /var/www +# so this project static files should be copied to: +# /vol1/1000/Docker/nginx/sites/bls_project_console/ +# and will be served from: +# /var/www/bls_project_console/ +# - Backend runs on the HOST at 127.0.0.1:3001. +# Nginx container reaches host via host.docker.internal. +# On Linux you typically need in nginx docker-compose: +# extra_hosts: +# - "host.docker.internal:host-gateway" + +server { + listen 80; + server_name blv-rd.tech; + + root /var/www/bls_project_console; + index index.html; + + # API reverse proxy + location /api/ { + proxy_pass http://host.docker.internal:3001; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # SPA history mode + location / { + try_files $uri $uri/ /index.html; + } + + access_log /var/log/nginx-custom/access.log; + error_log /var/log/nginx-custom/error.log warn; +} diff --git a/deploy/systemd/bls-project-console.service b/deploy/systemd/bls-project-console.service new file mode 100644 index 0000000..c7c1b93 --- /dev/null +++ b/deploy/systemd/bls-project-console.service @@ -0,0 +1,23 @@ +# Copy to: /etc/systemd/system/bls-project-console.service +# Then: systemctl daemon-reload && systemctl enable --now bls-project-console + +[Unit] +Description=BLS Project Console (Node/Express) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/vol1/1000/Docker/nginx/project/bls/bls_project_console + +# Use repo .env (copy .env.example -> .env) +EnvironmentFile=/vol1/1000/Docker/nginx/project/bls/bls_project_console/.env +Environment=NODE_ENV=production + +# Node 22 is installed on host. If node path differs, replace /usr/bin/node. +ExecStart=/usr/bin/node src/backend/server.js + +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/docs/redis-data-structure.md b/docs/redis-data-structure.md index 523d424..621ea66 100644 --- a/docs/redis-data-structure.md +++ b/docs/redis-data-structure.md @@ -15,7 +15,6 @@ **描述**: 统一存储所有项目的心跳信息,替代原有的分散键结构。 **数据格式**: - ```json [ { @@ -27,19 +26,11 @@ ``` **字段说明**: - - `projectName`: 项目名称(字符串) -- `apiBaseUrl`: 项目API基础URL(字符串),对应“API地址” -- `lastActiveAt`: 最新心跳时间(Unix 时间戳毫秒,数字),对应“最新心跳时间” - -**与需求字段对应**: - -- 项目名称 → `projectName` -- API地址 → `apiBaseUrl` -- 最新心跳时间 → `lastActiveAt` +- `apiBaseUrl`: 项目API基础URL(字符串) +- `lastActiveAt`: 最后活跃时间戳(毫秒,数字) **示例**: - ```json [ { @@ -66,7 +57,6 @@ **数据格式**: 每个列表元素是一个JSON字符串,包含日志信息。 **日志对象格式**: - ```json { "id": "string", @@ -78,7 +68,6 @@ ``` **字段说明**: - - `id`: 日志唯一标识符 - `timestamp`: ISO-8601格式的时间戳 - `level`: 日志级别(info, warn, error, debug) @@ -86,7 +75,6 @@ - `metadata`: 可选的附加元数据 **示例**: - ```json { "id": "log-1704067200000-abc123", @@ -109,7 +97,6 @@ **描述**: 存储发送给项目的控制指令。 **指令对象格式**: - ```json { "commandId": "string", @@ -123,7 +110,6 @@ ``` **字段说明**: - - `commandId`: 指令唯一标识符 - `timestamp`: ISO-8601格式的时间戳 - `source`: 指令来源(如"BLS Project Console") @@ -133,7 +119,6 @@ - `extraArgs`: 可选的额外参数 **示例**: - ```json { "commandId": "cmd-1704067200000-xyz789", @@ -171,16 +156,15 @@ - 备份现有数据(可选) 2. **执行迁移** - ```javascript import { migrateHeartbeatData } from './services/migrateHeartbeatData.js'; - + // 干运行模式(不实际写入数据) await migrateHeartbeatData({ dryRun: true }); - + // 实际迁移 await migrateHeartbeatData({ deleteOldKeys: false }); - + // 迁移并删除旧键 await migrateHeartbeatData({ deleteOldKeys: true }); ``` @@ -195,7 +179,6 @@ **端点**: `POST /api/projects/migrate` **请求体**: - ```json { "deleteOldKeys": false, @@ -204,12 +187,10 @@ ``` **参数说明**: - - `deleteOldKeys`: 是否删除旧的心跳键(默认: false) - `dryRun`: 是否为干运行模式(默认: false) **响应**: - ```json { "success": true, @@ -251,7 +232,6 @@ ### 健康检查 定期检查以下指标: - - Redis连接状态 - 项目列表完整性 - 日志队列长度 @@ -296,4 +276,4 @@ - [Redis数据类型](https://redis.io/docs/data-types/) - [项目OpenSpec规范](../openspec/specs/) -- [API文档](../docs/api-documentation.md) +- [API文档](../docs/api-documentation.md) \ No newline at end of file diff --git a/docs/redis-integration-protocol.md b/docs/redis-integration-protocol.md index 32adb17..2fffa80 100644 --- a/docs/redis-integration-protocol.md +++ b/docs/redis-integration-protocol.md @@ -2,8 +2,28 @@ 本文档定义“外部项目 ↔ BLS Project Console”之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**。 +注:本仓库对外暴露的 Redis 连接信息如下(供对方直接连接以写入心跳/日志): + +- 地址:`10.8.8.109` +- 端口:默认 `6379` +- 密码:无(空) + +示例(环境变量): + +``` +REDIS_HOST=10.8.8.109 +REDIS_PORT=6379 +REDIS_PASSWORD= +``` + +示例(redis-cli): + +``` +redis-cli -h 10.8.8.109 -p 6379 +``` + > 约束:每个需要关联本控制台的外部项目,必须在本项目使用的同一个 Redis 实例中: -> + > - 写入 2 个 Key(心跳 + 控制台信息) > - 命令下发为 HTTP API 调用 diff --git a/openspec/changes/refactor-project-heartbeat/proposal.md b/openspec/changes/refactor-project-heartbeat/proposal.md index 4b96a17..79504f6 100644 --- a/openspec/changes/refactor-project-heartbeat/proposal.md +++ b/openspec/changes/refactor-project-heartbeat/proposal.md @@ -1,16 +1,13 @@ # Change: Refactor Project Heartbeat Data Structure ## Why - 当前项目心跳数据使用分散的Redis键结构(`{projectName}_项目心跳`),导致以下问题: - 1. 难以统一管理和查询所有项目 2. 前端项目选择功能需要硬编码测试数据 3. 无法高效获取项目列表和状态 4. 数据迁移和维护成本高 ## What Changes - - **新增**统一的项目列表Redis键:`项目心跳` - **新增**数据迁移工具,支持从旧结构迁移到新结构 - **新增**项目列表API端点:`GET /api/projects` @@ -20,8 +17,7 @@ - **更新**OpenSpec规范文档,反映新的API和数据结构 ## Impact - -- Affected specs: +- Affected specs: - `specs/logging/spec.md` - 更新日志API响应格式 - `specs/command/spec.md` - 新增项目列表和迁移API - `specs/redis-connection/spec.md` - 新增项目列表相关API @@ -41,7 +37,6 @@ - `src/frontend/App.vue` - 修正健康检查端点 ## Migration Plan - 1. 执行数据迁移:调用`POST /api/projects/migrate` 2. 验证迁移结果:检查`项目心跳`键包含所有项目 3. 测试项目选择功能:确认前端能正确显示项目列表 @@ -49,16 +44,13 @@ 5. 清理旧键(可选):调用迁移API并设置`deleteOldKeys: true` ## Backward Compatibility - 系统保持向后兼容: - - 优先读取新的项目列表结构 - 如果新结构中未找到项目,回退到旧结构 - 支持平滑过渡,无需立即删除旧键 ## Benefits - - 统一的项目管理,提高可维护性 - 前端显示真实项目数据,移除测试假数据 - 提高查询效率,减少Redis操作次数 -- 支持未来功能扩展(如项目分组、搜索等) +- 支持未来功能扩展(如项目分组、搜索等) \ No newline at end of file diff --git a/openspec/changes/refactor-project-heartbeat/specs/redis-connection/spec.md b/openspec/changes/refactor-project-heartbeat/specs/redis-connection/spec.md index e3d6c0b..b2fd329 100644 --- a/openspec/changes/refactor-project-heartbeat/specs/redis-connection/spec.md +++ b/openspec/changes/refactor-project-heartbeat/specs/redis-connection/spec.md @@ -1,50 +1,41 @@ ## ADDED Requirements ### Requirement: Project List Management - The system SHALL provide a unified project list structure in Redis to manage all project heartbeats. #### Scenario: Getting all projects - - **WHEN** the user requests the project list - **THEN** the system SHALL return all projects with their heartbeat status - **AND** each project SHALL include: project name, API base URL, last active time, and online status #### Scenario: Project status calculation - - **WHEN** calculating project status - **THEN** the system SHALL determine online/offline based on last active time and offline threshold - **AND** the system SHALL return the age in milliseconds ### Requirement: Data Migration Support - The system SHALL support migrating heartbeat data from old structure to new unified structure. #### Scenario: Migrating heartbeat data - - **WHEN** the migration process is triggered - **THEN** the system SHALL read all old heartbeat keys - **AND** convert them to the new unified list structure - **AND** optionally delete old keys after successful migration #### Scenario: Dry run migration - - **WHEN** migration is executed with dryRun flag - **THEN** the system SHALL validate data without writing - **AND** return the migration result for review ### Requirement: Backward Compatibility - The system SHALL maintain backward compatibility with old heartbeat data structure. #### Scenario: Reading from new structure - - **WHEN** reading project heartbeat - **THEN** the system SHALL first try to read from the new unified list - **AND** fall back to old structure if not found #### Scenario: Gradual migration - - **WHEN** old structure data is detected - **THEN** the system SHALL continue to work with old data -- **AND** allow migration at a later time +- **AND** allow migration at a later time \ No newline at end of file diff --git a/openspec/changes/refactor-project-heartbeat/tasks.md b/openspec/changes/refactor-project-heartbeat/tasks.md index 41f7619..1e3e764 100644 --- a/openspec/changes/refactor-project-heartbeat/tasks.md +++ b/openspec/changes/refactor-project-heartbeat/tasks.md @@ -1,5 +1,4 @@ ## 1. Redis数据结构重构 - - [x] 1.1 在redisKeys.js中添加projectsListKey()函数 - [x] 1.2 创建数据迁移工具migrateHeartbeatData.js - [x] 1.3 实现从分散键到统一列表的迁移逻辑 @@ -7,7 +6,6 @@ - [x] 1.5 实现updateProjectHeartbeat()函数 ## 2. 后端API开发 - - [x] 2.1 创建项目列表API routes/projects.js - [x] 2.2 实现GET /api/projects端点 - [x] 2.3 实现POST /api/projects/migrate端点 @@ -16,7 +14,6 @@ - [x] 2.6 在server.js中注册项目列表路由 ## 3. 前端代码修改 - - [x] 3.1 更新ProjectSelector.vue移除假数据 - [x] 3.2 实现从API获取项目列表 - [x] 3.3 实现项目状态显示 @@ -29,7 +26,6 @@ - [x] 3.10 修正App.vue健康检查端点 ## 4. 文档更新 - - [x] 4.1 创建Redis数据结构文档 - [x] 4.2 更新logging OpenSpec规范 - [x] 4.3 更新command OpenSpec规范 @@ -38,18 +34,16 @@ - [x] 4.6 创建OpenSpec变更提案tasks.md ## 5. 测试开发 - - [ ] 5.1 编写数据迁移单元测试 - [ ] 5.2 编写项目列表API集成测试 - [ ] 5.3 编写ProjectSelector组件测试 - [ ] 5.4 编写性能测试 ## 6. 代码质量与验证 - - [ ] 6.1 运行ESLint检查 - [ ] 6.2 运行Prettier格式化 - [ ] 6.3 验证项目选择功能 - [ ] 6.4 验证日志读取功能 - [ ] 6.5 验证命令发送功能 - [ ] 6.6 验证数据迁移功能 -- [ ] 6.7 性能测试和优化 +- [ ] 6.7 性能测试和优化 \ No newline at end of file diff --git a/openspec/specs/command/spec.md b/openspec/specs/command/spec.md index 5b40c79..6d08ad7 100644 --- a/openspec/specs/command/spec.md +++ b/openspec/specs/command/spec.md @@ -1,54 +1,44 @@ # Command Capability Specification ## Overview - This specification defines the command capability for the BLS Project Console, which allows users to send console commands to Redis queues for other programs to read and execute. ## Requirements ### Requirement: Command Sending to Redis - The system SHALL send commands to a Redis queue. #### Scenario: Sending a command to Redis queue - - **WHEN** the user enters a command in the console - **AND** clicks the "Send" button - **THEN** the command SHALL be sent to the Redis queue - **AND** the user SHALL receive a success confirmation ### Requirement: Command Validation - The system SHALL validate commands before sending them to Redis. #### Scenario: Validating an empty command - - **WHEN** the user tries to send an empty command - **THEN** the system SHALL display an error message - **AND** the command SHALL NOT be sent to Redis #### Scenario: Validating a command with invalid characters - - **WHEN** the user tries to send a command with invalid characters - **THEN** the system SHALL display an error message - **AND** the command SHALL NOT be sent to Redis ### Requirement: Command History - The system SHALL maintain a history of sent commands. #### Scenario: Viewing command history - - **WHEN** the user opens the command history - **THEN** the system SHALL display a list of previously sent commands - **AND** the user SHALL be able to select a command from the history to resend ### Requirement: Command Response Handling - The system SHALL handle responses from commands sent to Redis. #### Scenario: Receiving a command response - - **WHEN** a command response is received from Redis - **THEN** the system SHALL display the response in the console - **AND** the response SHALL be associated with the original command @@ -56,7 +46,6 @@ The system SHALL handle responses from commands sent to Redis. ## Data Model ### Command - ```json { "id": "string", @@ -67,7 +56,6 @@ The system SHALL handle responses from commands sent to Redis. ``` ### Command Response - ```json { "id": "string", @@ -81,7 +69,6 @@ The system SHALL handle responses from commands sent to Redis. ## API Endpoints ### POST /api/commands - - **Description**: Send a command to a project's API endpoint - **Request Body**: ```json @@ -103,7 +90,6 @@ The system SHALL handle responses from commands sent to Redis. ``` ### GET /api/projects - - **Description**: Get list of all projects with their heartbeat status - **Response**: ```json @@ -125,7 +111,6 @@ The system SHALL handle responses from commands sent to Redis. ``` ### POST /api/projects/migrate - - **Description**: Migrate heartbeat data from old structure to new unified structure - **Request Body**: ```json @@ -147,7 +132,6 @@ The system SHALL handle responses from commands sent to Redis. ``` ### GET /api/commands/history - - **Description**: Get command history (deprecated - use project logs instead) - **Query Parameters**: - `limit`: Maximum number of commands to return (default: 50) @@ -155,6 +139,5 @@ The system SHALL handle responses from commands sent to Redis. - **Response**: Array of command objects ### GET /api/commands/:id/response - - **Description**: Get response for a specific command (deprecated - use project logs instead) - **Response**: Command response object diff --git a/openspec/specs/logging/spec.md b/openspec/specs/logging/spec.md index 4bb5900..dd8a64b 100644 --- a/openspec/specs/logging/spec.md +++ b/openspec/specs/logging/spec.md @@ -1,53 +1,43 @@ # Logging Capability Specification ## Overview - This specification defines the logging capability for the BLS Project Console, which allows the system to read log records from Redis queues and display them in the console interface. ## Requirements ### Requirement: Log Reading from Redis - The system SHALL read log records from a Redis queue. #### Scenario: Reading logs from Redis queue - - **WHEN** the server starts - **THEN** it SHALL establish a connection to the Redis queue - **AND** it SHALL begin listening for new log records - **AND** it SHALL store log records in memory for display ### Requirement: Log Display in Console - The system SHALL display log records in a user-friendly format. #### Scenario: Displaying logs in the console - - **WHEN** a log record is received from Redis - **THEN** it SHALL be added to the log list in the console - **AND** it SHALL display the log timestamp, level, and message - **AND** it SHALL support scrolling through historical logs ### Requirement: Log Filtering - The system SHALL allow users to filter logs by level and time range. #### Scenario: Filtering logs by level - - **WHEN** the user selects a log level filter - **THEN** only logs with the selected level SHALL be displayed #### Scenario: Filtering logs by time range - - **WHEN** the user selects a time range - **THEN** only logs within the specified range SHALL be displayed ### Requirement: Log Auto-Refresh - The system SHALL automatically refresh logs in real-time. #### Scenario: Real-time log updates - - **WHEN** a new log is added to the Redis queue - **THEN** it SHALL be automatically displayed in the console - **AND** the console SHALL scroll to the latest log if the user is viewing the end @@ -55,7 +45,6 @@ The system SHALL automatically refresh logs in real-time. ## Data Model ### Log Record - ```json { "id": "string", @@ -69,7 +58,6 @@ The system SHALL automatically refresh logs in real-time. ## API Endpoints ### GET /api/logs - - **Description**: Get log records for a specific project - **Query Parameters**: - `projectName`: Project name (required) @@ -97,6 +85,5 @@ The system SHALL automatically refresh logs in real-time. ``` ### GET /api/logs/live - - **Description**: Establish a WebSocket connection for real-time log updates - **Response**: Continuous stream of log records diff --git a/openspec/specs/redis-connection/spec.md b/openspec/specs/redis-connection/spec.md index 40a93ab..b84a5ca 100644 --- a/openspec/specs/redis-connection/spec.md +++ b/openspec/specs/redis-connection/spec.md @@ -1,59 +1,48 @@ # Redis Connection Capability Specification ## Overview - This specification defines the Redis connection capability for the BLS Project Console, which manages the connection between the system and the Redis server for reading logs and sending commands. ## Requirements ### Requirement: Redis Connection Establishment - The system SHALL establish a connection to the Redis server. #### Scenario: Establishing Redis connection on server start - - **WHEN** the server starts - **THEN** it SHALL attempt to connect to the Redis server - **AND** it SHALL log the connection status ### Requirement: Redis Connection Configuration - The system SHALL allow configuration of Redis connection parameters. #### Scenario: Configuring Redis connection via environment variables - - **WHEN** the server starts with Redis environment variables set - **THEN** it SHALL use those variables to configure the Redis connection - **AND** it SHALL override default values ### Requirement: Redis Connection Reconnection - The system SHALL automatically reconnect to Redis if the connection is lost. #### Scenario: Reconnecting to Redis after connection loss - - **WHEN** the Redis connection is lost - **THEN** the system SHALL attempt to reconnect with exponential backoff - **AND** it SHALL log each reconnection attempt - **AND** it SHALL notify the user when connection is restored ### Requirement: Redis Connection Error Handling - The system SHALL handle Redis connection errors gracefully. #### Scenario: Handling Redis connection failure - - **WHEN** the system fails to connect to Redis - **THEN** it SHALL log the error - **AND** it SHALL display an error message to the user - **AND** it SHALL continue attempting to reconnect ### Requirement: Redis Connection Monitoring - The system SHALL monitor the Redis connection status. #### Scenario: Monitoring Redis connection status - - **WHEN** the Redis connection status changes - **THEN** the system SHALL update the connection status in the UI - **AND** it SHALL log the status change @@ -61,7 +50,6 @@ The system SHALL monitor the Redis connection status. ## Data Model ### Redis Connection Configuration - ```json { "host": "string", @@ -77,7 +65,6 @@ The system SHALL monitor the Redis connection status. ``` ### Redis Connection Status - ```json { "status": "string", // e.g., connecting, connected, disconnected, error @@ -90,7 +77,6 @@ The system SHALL monitor the Redis connection status. ## API Endpoints ### GET /api/redis/status - - **Description**: Get Redis connection status - **Response**: ```json @@ -103,7 +89,6 @@ The system SHALL monitor the Redis connection status. ``` ### POST /api/redis/reconnect - - **Description**: Manually reconnect to Redis - **Response**: ```json @@ -114,7 +99,6 @@ The system SHALL monitor the Redis connection status. ``` ### GET /api/projects - - **Description**: Get list of all projects from Redis - **Response**: ```json @@ -136,7 +120,6 @@ The system SHALL monitor the Redis connection status. ``` ### POST /api/projects/migrate - - **Description**: Migrate heartbeat data from old structure to new unified structure - **Request Body**: ```json diff --git a/src/backend/routes/commands.js b/src/backend/routes/commands.js index d46bea4..7b134db 100644 --- a/src/backend/routes/commands.js +++ b/src/backend/routes/commands.js @@ -53,15 +53,14 @@ function buildTargetUrl(apiBaseUrl, apiName) { function truncateForLog(value, maxLen = 2000) { if (value == null) return value; - if (typeof value === 'string') - return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value; + if (typeof value === 'string') return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value; return value; } async function getProjectHeartbeat(redis, projectName) { try { - const projectsList = await getProjectsList(redis); - const project = projectsList.find((p) => p.projectName === projectName); + const projectsList = await getProjectsList(); + const project = projectsList.find(p => p.projectName === projectName); if (project) { return { @@ -70,10 +69,7 @@ async function getProjectHeartbeat(redis, projectName) { }; } } catch (err) { - console.error( - '[getProjectHeartbeat] Failed to get from projects list:', - err, - ); + console.error('[getProjectHeartbeat] Failed to get from projects list:', err); } const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName)); @@ -137,10 +133,7 @@ router.post('/', async (req, res) => { } const heartbeatKey = projectHeartbeatKey(targetProjectName.trim()); - const heartbeat = await getProjectHeartbeat( - redis, - targetProjectName.trim(), - ); + const heartbeat = await getProjectHeartbeat(redis, targetProjectName.trim()); if (!heartbeat) { return res.status(503).json({ @@ -149,10 +142,7 @@ router.post('/', async (req, res) => { }); } - const apiBaseUrl = - typeof heartbeat.apiBaseUrl === 'string' - ? heartbeat.apiBaseUrl.trim() - : ''; + const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl.trim() : ''; const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt); if (!apiBaseUrl) { @@ -162,16 +152,9 @@ router.post('/', async (req, res) => { }); } - const offlineThresholdMs = Number.parseInt( - process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', - 10, - ); + const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10); const now = Date.now(); - if ( - !lastActiveAt || - (Number.isFinite(offlineThresholdMs) && - now - lastActiveAt > offlineThresholdMs) - ) { + if (!lastActiveAt || (Number.isFinite(offlineThresholdMs) && now - lastActiveAt > offlineThresholdMs)) { return res.status(503).json({ success: false, message: '目标项目已离线(心跳超时)', @@ -187,10 +170,7 @@ router.post('/', async (req, res) => { }); } - const timeoutMs = Number.parseInt( - process.env.COMMAND_API_TIMEOUT_MS || '5000', - 10, - ); + const timeoutMs = Number.parseInt(process.env.COMMAND_API_TIMEOUT_MS || '5000', 10); const resp = await axios.post(targetUrl, payload, { timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000, validateStatus: () => true, @@ -229,4 +209,4 @@ router.post('/', async (req, res) => { } }); -export default router; +export default router; \ No newline at end of file diff --git a/src/backend/routes/logs.js b/src/backend/routes/logs.js index 4862594..e32be45 100644 --- a/src/backend/routes/logs.js +++ b/src/backend/routes/logs.js @@ -3,10 +3,7 @@ import express from 'express'; const router = express.Router(); import { getRedisClient } from '../services/redisClient.js'; -import { - projectConsoleKey, - projectHeartbeatKey, -} from '../services/redisKeys.js'; +import { projectConsoleKey, projectHeartbeatKey } from '../services/redisKeys.js'; import { getProjectsList } from '../services/migrateHeartbeatData.js'; function parsePositiveInt(value, defaultValue) { @@ -28,8 +25,8 @@ function parseLastActiveAt(value) { async function getProjectHeartbeat(redis, projectName) { try { - const projectsList = await getProjectsList(redis); - const project = projectsList.find((p) => p.projectName === projectName); + const projectsList = await getProjectsList(); + const project = projectsList.find(p => p.projectName === projectName); if (project) { return { @@ -38,10 +35,7 @@ async function getProjectHeartbeat(redis, projectName) { }; } } catch (err) { - console.error( - '[getProjectHeartbeat] Failed to get from projects list:', - err, - ); + console.error('[getProjectHeartbeat] Failed to get from projects list:', err); } const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName)); @@ -58,10 +52,7 @@ async function getProjectHeartbeat(redis, projectName) { // 获取日志列表 router.get('/', async (req, res) => { - const projectName = - typeof req.query.projectName === 'string' - ? req.query.projectName.trim() - : ''; + const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : ''; const limit = parsePositiveInt(req.query.limit, 200); if (!projectName) { @@ -84,61 +75,50 @@ router.get('/', async (req, res) => { const key = projectConsoleKey(projectName); const list = await redis.lRange(key, -limit, -1); - const logs = list.map((raw, idx) => { - try { - const parsed = JSON.parse(raw); - const timestamp = parsed.timestamp || new Date().toISOString(); - const level = (parsed.level || 'info').toString().toLowerCase(); - const message = parsed.message != null ? String(parsed.message) : ''; - return { - id: parsed.id || `log-${timestamp}-${idx}`, - timestamp, - level, - message, - metadata: - parsed.metadata && typeof parsed.metadata === 'object' - ? parsed.metadata - : undefined, - }; - } catch { - return { - id: `log-${Date.now()}-${idx}`, - timestamp: new Date().toISOString(), - level: 'info', - message: raw, - }; - } - }); + const logs = list + .map((raw, idx) => { + try { + const parsed = JSON.parse(raw); + const timestamp = parsed.timestamp || new Date().toISOString(); + const level = (parsed.level || 'info').toString().toLowerCase(); + const message = parsed.message != null ? String(parsed.message) : ''; + return { + id: parsed.id || `log-${timestamp}-${idx}`, + timestamp, + level, + message, + metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined, + }; + } catch { + return { + id: `log-${Date.now()}-${idx}`, + timestamp: new Date().toISOString(), + level: 'info', + message: raw, + }; + } + }); const heartbeat = await getProjectHeartbeat(redis, projectName); - const offlineThresholdMs = Number.parseInt( - process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', - 10, - ); + const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10); const now = Date.now(); const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt); const ageMs = lastActiveAt ? now - lastActiveAt : null; - const isOnline = - lastActiveAt && Number.isFinite(offlineThresholdMs) - ? ageMs <= offlineThresholdMs - : Boolean(lastActiveAt); + const isOnline = lastActiveAt && Number.isFinite(offlineThresholdMs) + ? ageMs <= offlineThresholdMs + : Boolean(lastActiveAt); const computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null; return res.status(200).json({ logs, projectStatus: computedStatus || null, - heartbeat: heartbeat - ? { - apiBaseUrl: - typeof heartbeat.apiBaseUrl === 'string' - ? heartbeat.apiBaseUrl - : null, - lastActiveAt: lastActiveAt || null, - isOnline, - ageMs, - } - : null, + heartbeat: heartbeat ? { + apiBaseUrl: typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null, + lastActiveAt: lastActiveAt || null, + isOnline, + ageMs, + } : null, }); } catch (err) { console.error('Failed to read logs', err); @@ -150,4 +130,4 @@ router.get('/', async (req, res) => { } }); -export default router; +export default router; \ No newline at end of file diff --git a/src/backend/routes/projects.js b/src/backend/routes/projects.js index b04438d..5896264 100644 --- a/src/backend/routes/projects.js +++ b/src/backend/routes/projects.js @@ -3,10 +3,7 @@ import express from 'express'; const router = express.Router(); import { getRedisClient } from '../services/redisClient.js'; -import { - migrateHeartbeatData, - getProjectsList, -} from '../services/migrateHeartbeatData.js'; +import { migrateHeartbeatData, getProjectsList } from '../services/migrateHeartbeatData.js'; function parseLastActiveAt(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; @@ -20,10 +17,7 @@ function parseLastActiveAt(value) { } function computeProjectStatus(project) { - const offlineThresholdMs = Number.parseInt( - process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', - 10, - ); + const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10); const now = Date.now(); const lastActiveAt = parseLastActiveAt(project?.lastActiveAt); @@ -36,9 +30,7 @@ function computeProjectStatus(project) { } const ageMs = now - lastActiveAt; - const isOnline = Number.isFinite(offlineThresholdMs) - ? ageMs <= offlineThresholdMs - : true; + const isOnline = Number.isFinite(offlineThresholdMs) ? ageMs <= offlineThresholdMs : true; return { status: isOnline ? 'online' : 'offline', @@ -58,8 +50,8 @@ router.get('/', async (req, res) => { }); } - const projectsList = await getProjectsList(redis); - const projects = projectsList.map((project) => { + const projectsList = await getProjectsList(); + const projects = projectsList.map(project => { const statusInfo = computeProjectStatus(project); return { id: project.projectName, @@ -89,11 +81,9 @@ router.post('/migrate', async (req, res) => { const { deleteOldKeys = false, dryRun = false } = req.body; try { - const redis = req.app?.locals?.redis || (await getRedisClient()); const result = await migrateHeartbeatData({ deleteOldKeys: Boolean(deleteOldKeys), dryRun: Boolean(dryRun), - redis, }); return res.status(200).json({ @@ -111,4 +101,4 @@ router.post('/migrate', async (req, res) => { } }); -export default router; +export default router; \ No newline at end of file diff --git a/src/backend/server.js b/src/backend/server.js index e7707ca..80d6243 100644 --- a/src/backend/server.js +++ b/src/backend/server.js @@ -1,9 +1,23 @@ +import express from 'express'; +import cors from 'cors'; +import logRoutes from './routes/logs.js'; +import commandRoutes from './routes/commands.js'; +import projectRoutes from './routes/projects.js'; import { getRedisClient } from './services/redisClient.js'; -import { createApp } from './app.js'; +const app = express(); const PORT = 3001; -const app = createApp(); +app.use(cors()); +app.use(express.json()); + +app.use('/api/logs', logRoutes); +app.use('/api/commands', commandRoutes); +app.use('/api/projects', projectRoutes); + +app.get('/api/health', (req, res) => { + res.status(200).json({ status: 'ok' }); +}); // 启动服务器 const server = app.listen(PORT, async () => { @@ -28,4 +42,4 @@ process.on('SIGINT', async () => { } finally { server.close(() => process.exit(0)); } -}); +}); \ No newline at end of file diff --git a/src/backend/services/migrateHeartbeatData.js b/src/backend/services/migrateHeartbeatData.js index cc70523..632bf8f 100644 --- a/src/backend/services/migrateHeartbeatData.js +++ b/src/backend/services/migrateHeartbeatData.js @@ -1,7 +1,7 @@ import { getRedisClient } from './redisClient.js'; import { projectsListKey } from './redisKeys.js'; -export function parseLastActiveAt(value) { +function parseLastActiveAt(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { const asNum = Number(value); @@ -12,65 +12,13 @@ export function parseLastActiveAt(value) { return null; } -export function normalizeProjectEntry(entry) { - if (!entry || typeof entry !== 'object') return null; +export async function migrateHeartbeatData(options = {}) { + const { deleteOldKeys = false, dryRun = false } = options; - const projectName = - typeof entry.projectName === 'string' ? entry.projectName.trim() : ''; - if (!projectName) return null; - - const apiBaseUrl = - typeof entry.apiBaseUrl === 'string' && entry.apiBaseUrl.trim() - ? entry.apiBaseUrl.trim() - : null; - - const lastActiveAt = parseLastActiveAt(entry.lastActiveAt); - - return { - projectName, - apiBaseUrl, - lastActiveAt: lastActiveAt || null, - }; -} - -export function normalizeProjectsList(list) { - if (!Array.isArray(list)) return []; - - const byName = new Map(); - for (const item of list) { - const normalized = normalizeProjectEntry(item); - if (!normalized) continue; - - const existing = byName.get(normalized.projectName); - if (!existing) { - byName.set(normalized.projectName, normalized); - continue; - } - - const existingTs = existing.lastActiveAt || 0; - const nextTs = normalized.lastActiveAt || 0; - byName.set( - normalized.projectName, - nextTs >= existingTs ? normalized : existing, - ); - } - - return Array.from(byName.values()); -} - -async function getReadyRedis(redisOverride) { - const redis = redisOverride || (await getRedisClient()); + const redis = await getRedisClient(); if (!redis?.isReady) { throw new Error('Redis 未就绪'); } - return redis; -} - -export async function migrateHeartbeatData(options = {}) { - const { deleteOldKeys = false, dryRun = false, redis: redisOverride } = - options; - - const redis = await getReadyRedis(redisOverride); console.log('[migrate] 开始迁移心跳数据...'); @@ -96,37 +44,38 @@ export async function migrateHeartbeatData(options = {}) { } const projectName = key.replace('_项目心跳', ''); - const project = normalizeProjectEntry({ - projectName, - apiBaseUrl: heartbeat?.apiBaseUrl, - lastActiveAt: heartbeat?.lastActiveAt, - }); + const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null; + const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt); - if (!project?.apiBaseUrl) { + if (!apiBaseUrl) { console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`); continue; } + const project = { + projectName, + apiBaseUrl, + lastActiveAt: lastActiveAt || null, + }; + projectsList.push(project); console.log(`[migrate] 添加项目: ${projectName}`); } - const normalizedProjectsList = normalizeProjectsList(projectsList); - - console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`); + console.log(`[migrate] 共迁移 ${projectsList.length} 个项目`); if (dryRun) { console.log('[migrate] 干运行模式,不写入数据'); return { success: true, - migrated: normalizedProjectsList.length, - projects: normalizedProjectsList, + migrated: projectsList.length, + projects: projectsList, dryRun: true, }; } const listKey = projectsListKey(); - await redis.set(listKey, JSON.stringify(normalizedProjectsList)); + await redis.set(listKey, JSON.stringify(projectsList)); console.log(`[migrate] 已写入项目列表到: ${listKey}`); if (deleteOldKeys) { @@ -141,8 +90,8 @@ export async function migrateHeartbeatData(options = {}) { return { success: true, - migrated: normalizedProjectsList.length, - projects: normalizedProjectsList, + migrated: projectsList.length, + projects: projectsList, listKey, deleteOldKeys, }; @@ -152,8 +101,11 @@ export async function migrateHeartbeatData(options = {}) { } } -export async function getProjectsList(redisOverride) { - const redis = await getReadyRedis(redisOverride); +export async function getProjectsList() { + const redis = await getRedisClient(); + if (!redis?.isReady) { + throw new Error('Redis 未就绪'); + } const listKey = projectsListKey(); const raw = await redis.get(listKey); @@ -164,35 +116,28 @@ export async function getProjectsList(redisOverride) { try { const list = JSON.parse(raw); - return normalizeProjectsList(list); + return Array.isArray(list) ? list : []; } catch (err) { console.error('[getProjectsList] 解析项目列表失败:', err); return []; } } -export async function updateProjectHeartbeat( - projectName, - heartbeatData, - redisOverride, -) { - const redis = await getReadyRedis(redisOverride); - - const projectsList = await getProjectsList(redis); - const existingIndex = projectsList.findIndex( - (p) => p.projectName === projectName, - ); - - const project = normalizeProjectEntry({ - projectName, - apiBaseUrl: heartbeatData?.apiBaseUrl, - lastActiveAt: heartbeatData?.lastActiveAt, - }); - - if (!project) { - throw new Error('无效的项目心跳数据'); +export async function updateProjectHeartbeat(projectName, heartbeatData) { + const redis = await getRedisClient(); + if (!redis?.isReady) { + throw new Error('Redis 未就绪'); } + const projectsList = await getProjectsList(); + const existingIndex = projectsList.findIndex(p => p.projectName === projectName); + + const project = { + projectName, + apiBaseUrl: heartbeatData.apiBaseUrl || null, + lastActiveAt: parseLastActiveAt(heartbeatData.lastActiveAt) || null, + }; + if (existingIndex >= 0) { projectsList[existingIndex] = project; } else { @@ -203,4 +148,4 @@ export async function updateProjectHeartbeat( await redis.set(listKey, JSON.stringify(projectsList)); return project; -} +} \ No newline at end of file diff --git a/src/frontend/App.vue b/src/frontend/App.vue index 71ee31a..3fcf41f 100644 --- a/src/frontend/App.vue +++ b/src/frontend/App.vue @@ -1,46 +1,28 @@