feat: 重构项目心跳数据结构并实现相关功能
- 重构Redis心跳数据结构,使用统一的项目列表键 - 新增数据迁移工具和API端点 - 更新前端以使用真实项目数据 - 添加系统部署配置和文档 - 修复代码格式和样式问题
This commit is contained in:
@@ -8,10 +8,6 @@ REDIS_PASSWORD= # Redis password (leave empty if not set)
|
|||||||
REDIS_DB=0 # Redis database number (default: 0)
|
REDIS_DB=0 # Redis database number (default: 0)
|
||||||
REDIS_CONNECT_TIMEOUT_MS=2000 # Connection timeout in ms (default: 2000)
|
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)
|
# Command control (HTTP)
|
||||||
# Timeout for calling target project APIs (ms)
|
# Timeout for calling target project APIs (ms)
|
||||||
COMMAND_API_TIMEOUT_MS=5000
|
COMMAND_API_TIMEOUT_MS=5000
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
# BLS Project Console 系统功能优化与修复计划
|
# BLS Project Console 系统功能优化与修复计划
|
||||||
|
|
||||||
## 📋 任务概述
|
## 📋 任务概述
|
||||||
|
|
||||||
完成界面显示修复、Redis数据结构重构、相关代码修改、文档更新和测试验证。
|
完成界面显示修复、Redis数据结构重构、相关代码修改、文档更新和测试验证。
|
||||||
|
|
||||||
## 🔍 当前问题分析
|
## 🔍 当前问题分析
|
||||||
|
|
||||||
### 1. 界面显示问题
|
### 1. 界面显示问题
|
||||||
|
|
||||||
- **ProjectSelector.vue** (第54-97行) 包含硬编码的6个测试假数据
|
- **ProjectSelector.vue** (第54-97行) 包含硬编码的6个测试假数据
|
||||||
- 没有从Redis读取真实项目心跳数据
|
- 没有从Redis读取真实项目心跳数据
|
||||||
- 界面展示的是静态测试数据,而非真实有效的项目
|
- 界面展示的是静态测试数据,而非真实有效的项目
|
||||||
|
|
||||||
### 2. Redis数据结构问题
|
### 2. Redis数据结构问题
|
||||||
|
|
||||||
- 当前使用分散的键:`{projectName}_项目心跳`
|
- 当前使用分散的键:`{projectName}_项目心跳`
|
||||||
- 每个项目的心跳数据独立存储,难以统一管理
|
- 每个项目的心跳数据独立存储,难以统一管理
|
||||||
- 缺少统一的项目列表查询机制
|
- 缺少统一的项目列表查询机制
|
||||||
|
|
||||||
### 3. 后端API现状
|
### 3. 后端API现状
|
||||||
|
|
||||||
- **logs.js** 和 **commands.js** 都从 `{projectName}_项目心跳` 读取数据
|
- **logs.js** 和 **commands.js** 都从 `{projectName}_项目心跳` 读取数据
|
||||||
- **redisKeys.js** 定义了 `projectHeartbeatKey(projectName)` 函数
|
- **redisKeys.js** 定义了 `projectHeartbeatKey(projectName)` 函数
|
||||||
- 缺少获取所有项目列表的API端点
|
- 缺少获取所有项目列表的API端点
|
||||||
|
|
||||||
### 4. 测试缺失
|
### 4. 测试缺失
|
||||||
|
|
||||||
- 项目中没有测试文件
|
- 项目中没有测试文件
|
||||||
- 缺少单元测试和集成测试
|
- 缺少单元测试和集成测试
|
||||||
|
|
||||||
@@ -34,124 +29,104 @@
|
|||||||
### 阶段1:Redis数据结构重构
|
### 阶段1:Redis数据结构重构
|
||||||
|
|
||||||
#### 1.1 新增Redis键定义
|
#### 1.1 新增Redis键定义
|
||||||
|
|
||||||
- 在 `redisKeys.js` 中添加 `projectsListKey()` 函数
|
- 在 `redisKeys.js` 中添加 `projectsListKey()` 函数
|
||||||
- 定义统一的项目列表键:`项目心跳`
|
- 定义统一的项目列表键:`项目心跳`
|
||||||
|
|
||||||
#### 1.2 创建数据迁移工具
|
#### 1.2 创建数据迁移工具
|
||||||
|
|
||||||
- 创建 `src/backend/services/migrateHeartbeatData.js`
|
- 创建 `src/backend/services/migrateHeartbeatData.js`
|
||||||
- 实现从分散键到统一列表的迁移逻辑
|
- 实现从分散键到统一列表的迁移逻辑
|
||||||
- 确保数据完整性和一致性
|
- 确保数据完整性和一致性
|
||||||
|
|
||||||
#### 1.3 更新心跳数据结构
|
#### 1.3 更新心跳数据结构
|
||||||
|
|
||||||
- 新结构:`[{ "apiBaseUrl": "...", "lastActiveAt": 1234567890, "projectName": "..." }]`
|
- 新结构:`[{ "apiBaseUrl": "...", "lastActiveAt": 1234567890, "projectName": "..." }]`
|
||||||
- 每个对象包含:API地址、最新心跳时间、项目名称
|
- 每个对象包含:API地址、最新心跳时间、项目名称
|
||||||
|
|
||||||
### 阶段2:后端API开发
|
### 阶段2:后端API开发
|
||||||
|
|
||||||
#### 2.1 创建项目列表API
|
#### 2.1 创建项目列表API
|
||||||
|
|
||||||
- 在 `src/backend/routes/` 下创建 `projects.js`
|
- 在 `src/backend/routes/` 下创建 `projects.js`
|
||||||
- 实现 `GET /api/projects` 端点
|
- 实现 `GET /api/projects` 端点
|
||||||
- 从Redis读取项目列表并返回
|
- 从Redis读取项目列表并返回
|
||||||
|
|
||||||
#### 2.2 更新现有API
|
#### 2.2 更新现有API
|
||||||
|
|
||||||
- 修改 `logs.js` 以兼容新的数据结构
|
- 修改 `logs.js` 以兼容新的数据结构
|
||||||
- 修改 `commands.js` 以兼容新的数据结构
|
- 修改 `commands.js` 以兼容新的数据结构
|
||||||
- 保持向后兼容性
|
- 保持向后兼容性
|
||||||
|
|
||||||
#### 2.3 添加数据迁移端点
|
#### 2.3 添加数据迁移端点
|
||||||
|
|
||||||
- 实现 `POST /api/projects/migrate` 端点
|
- 实现 `POST /api/projects/migrate` 端点
|
||||||
- 支持手动触发数据迁移
|
- 支持手动触发数据迁移
|
||||||
|
|
||||||
### 阶段3:前端代码修改
|
### 阶段3:前端代码修改
|
||||||
|
|
||||||
#### 3.1 更新ProjectSelector组件
|
#### 3.1 更新ProjectSelector组件
|
||||||
|
|
||||||
- 移除硬编码的测试数据(第54-97行)
|
- 移除硬编码的测试数据(第54-97行)
|
||||||
- 从 `/api/projects` 获取真实项目列表
|
- 从 `/api/projects` 获取真实项目列表
|
||||||
- 实现数据加载状态和错误处理
|
- 实现数据加载状态和错误处理
|
||||||
|
|
||||||
#### 3.2 更新projectStore
|
#### 3.2 更新projectStore
|
||||||
|
|
||||||
- 扩展状态管理以支持项目列表
|
- 扩展状态管理以支持项目列表
|
||||||
- 添加项目列表的响应式状态
|
- 添加项目列表的响应式状态
|
||||||
|
|
||||||
#### 3.3 更新LogView和CommandView
|
#### 3.3 更新LogView和CommandView
|
||||||
|
|
||||||
- 连接到真实API端点
|
- 连接到真实API端点
|
||||||
- 实现日志和命令的真实功能
|
- 实现日志和命令的真实功能
|
||||||
- 添加错误处理和用户反馈
|
- 添加错误处理和用户反馈
|
||||||
|
|
||||||
#### 3.4 更新App.vue
|
#### 3.4 更新App.vue
|
||||||
|
|
||||||
- 修正健康检查端点(从3000改为3001)
|
- 修正健康检查端点(从3000改为3001)
|
||||||
- 确保服务状态监控正常工作
|
- 确保服务状态监控正常工作
|
||||||
|
|
||||||
### 阶段4:文档更新
|
### 阶段4:文档更新
|
||||||
|
|
||||||
#### 4.1 更新技术文档
|
#### 4.1 更新技术文档
|
||||||
|
|
||||||
- 创建或更新 `docs/redis-data-structure.md`
|
- 创建或更新 `docs/redis-data-structure.md`
|
||||||
- 详细说明新的Redis数据结构设计
|
- 详细说明新的Redis数据结构设计
|
||||||
- 提供数据迁移指南
|
- 提供数据迁移指南
|
||||||
|
|
||||||
#### 4.2 更新OpenSpec规范
|
#### 4.2 更新OpenSpec规范
|
||||||
|
|
||||||
- 更新 `openspec/specs/logging/spec.md`
|
- 更新 `openspec/specs/logging/spec.md`
|
||||||
- 更新 `openspec/specs/command/spec.md`
|
- 更新 `openspec/specs/command/spec.md`
|
||||||
- 更新 `openspec/specs/redis-connection/spec.md`
|
- 更新 `openspec/specs/redis-connection/spec.md`
|
||||||
- 添加项目列表相关需求
|
- 添加项目列表相关需求
|
||||||
|
|
||||||
#### 4.3 创建OpenSpec变更提案
|
#### 4.3 创建OpenSpec变更提案
|
||||||
|
|
||||||
- 创建 `openspec/changes/refactor-project-heartbeat/`
|
- 创建 `openspec/changes/refactor-project-heartbeat/`
|
||||||
- 编写 `proposal.md`、`tasks.md` 和规范增量
|
- 编写 `proposal.md`、`tasks.md` 和规范增量
|
||||||
|
|
||||||
### 阶段5:测试开发
|
### 阶段5:测试开发
|
||||||
|
|
||||||
#### 5.1 单元测试
|
#### 5.1 单元测试
|
||||||
|
|
||||||
- 创建 `src/backend/services/__tests__/migrateHeartbeatData.test.js`
|
- 创建 `src/backend/services/__tests__/migrateHeartbeatData.test.js`
|
||||||
- 测试数据迁移功能
|
- 测试数据迁移功能
|
||||||
- 测试Redis键操作
|
- 测试Redis键操作
|
||||||
|
|
||||||
#### 5.2 集成测试
|
#### 5.2 集成测试
|
||||||
|
|
||||||
- 创建 `src/backend/routes/__tests__/projects.test.js`
|
- 创建 `src/backend/routes/__tests__/projects.test.js`
|
||||||
- 测试项目列表API
|
- 测试项目列表API
|
||||||
- 测试数据迁移API
|
- 测试数据迁移API
|
||||||
|
|
||||||
#### 5.3 前端测试
|
#### 5.3 前端测试
|
||||||
|
|
||||||
- 创建 `src/frontend/components/__tests__/ProjectSelector.test.js`
|
- 创建 `src/frontend/components/__tests__/ProjectSelector.test.js`
|
||||||
- 测试组件渲染和交互
|
- 测试组件渲染和交互
|
||||||
|
|
||||||
#### 5.4 性能测试
|
#### 5.4 性能测试
|
||||||
|
|
||||||
- 测试不同项目数量下的性能
|
- 测试不同项目数量下的性能
|
||||||
- 测试不同心跳频率下的性能
|
- 测试不同心跳频率下的性能
|
||||||
|
|
||||||
### 阶段6:代码质量与验证
|
### 阶段6:代码质量与验证
|
||||||
|
|
||||||
#### 6.1 代码规范检查
|
#### 6.1 代码规范检查
|
||||||
|
|
||||||
- 运行 ESLint 检查
|
- 运行 ESLint 检查
|
||||||
- 运行 Prettier 格式化
|
- 运行 Prettier 格式化
|
||||||
- 确保符合项目代码规范
|
- 确保符合项目代码规范
|
||||||
|
|
||||||
#### 6.2 功能验证
|
#### 6.2 功能验证
|
||||||
|
|
||||||
- 验证项目选择功能正常
|
- 验证项目选择功能正常
|
||||||
- 验证日志读取功能正常
|
- 验证日志读取功能正常
|
||||||
- 验证命令发送功能正常
|
- 验证命令发送功能正常
|
||||||
|
|
||||||
#### 6.3 性能验证
|
#### 6.3 性能验证
|
||||||
|
|
||||||
- 测试大量项目场景
|
- 测试大量项目场景
|
||||||
- 测试高频心跳场景
|
- 测试高频心跳场景
|
||||||
- 优化性能瓶颈
|
- 优化性能瓶颈
|
||||||
@@ -159,7 +134,6 @@
|
|||||||
## 📁 文件修改清单
|
## 📁 文件修改清单
|
||||||
|
|
||||||
### 新增文件
|
### 新增文件
|
||||||
|
|
||||||
- `src/backend/services/migrateHeartbeatData.js` - 数据迁移工具
|
- `src/backend/services/migrateHeartbeatData.js` - 数据迁移工具
|
||||||
- `src/backend/routes/projects.js` - 项目列表API
|
- `src/backend/routes/projects.js` - 项目列表API
|
||||||
- `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 迁移测试
|
- `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 迁移测试
|
||||||
@@ -171,7 +145,6 @@
|
|||||||
- `openspec/changes/refactor-project-heartbeat/specs/` - 规范增量
|
- `openspec/changes/refactor-project-heartbeat/specs/` - 规范增量
|
||||||
|
|
||||||
### 修改文件
|
### 修改文件
|
||||||
|
|
||||||
- `src/backend/services/redisKeys.js` - 添加项目列表键
|
- `src/backend/services/redisKeys.js` - 添加项目列表键
|
||||||
- `src/backend/routes/logs.js` - 更新心跳读取逻辑
|
- `src/backend/routes/logs.js` - 更新心跳读取逻辑
|
||||||
- `src/backend/routes/commands.js` - 更新心跳读取逻辑
|
- `src/backend/routes/commands.js` - 更新心跳读取逻辑
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ npm install
|
|||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=15
|
||||||
```
|
```
|
||||||
|
|
||||||
### 运行项目
|
### 运行项目
|
||||||
@@ -257,7 +257,7 @@ npm run format
|
|||||||
REDIS_HOST=redis-server
|
REDIS_HOST=redis-server
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=your-redis-password
|
REDIS_PASSWORD=your-redis-password
|
||||||
REDIS_DB=0
|
REDIS_DB=15
|
||||||
|
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
PORT=3001
|
PORT=3001
|
||||||
@@ -317,7 +317,7 @@ docker run -p 3001:3001 --env-file .env bls-project-console
|
|||||||
| 主机名 | REDIS_HOST | localhost | Redis服务器主机名 |
|
| 主机名 | REDIS_HOST | localhost | Redis服务器主机名 |
|
||||||
| 端口 | REDIS_PORT | 6379 | Redis服务器端口 |
|
| 端口 | REDIS_PORT | 6379 | Redis服务器端口 |
|
||||||
| 密码 | REDIS_PASSWORD | | Redis服务器密码 |
|
| 密码 | REDIS_PASSWORD | | Redis服务器密码 |
|
||||||
| 数据库索引 | REDIS_DB | 0 | Redis数据库索引 |
|
| 数据库索引 | REDIS_DB | 15 | Redis数据库索引 |
|
||||||
| 连接超时 | REDIS_CONNECT_TIMEOUT | 10000 | 连接超时时间(毫秒) |
|
| 连接超时 | REDIS_CONNECT_TIMEOUT | 10000 | 连接超时时间(毫秒) |
|
||||||
| 最大重试次数 | REDIS_MAX_RETRIES | 3 | 最大重试次数 |
|
| 最大重试次数 | REDIS_MAX_RETRIES | 3 | 最大重试次数 |
|
||||||
| 重连策略 | REDIS_RECONNECT_STRATEGY | exponential | 重连策略(exponential/fixed) |
|
| 重连策略 | REDIS_RECONNECT_STRATEGY | exponential | 重连策略(exponential/fixed) |
|
||||||
|
|||||||
21
deploy/docker/docker-compose.backend.yml
Normal file
21
deploy/docker/docker-compose.backend.yml
Normal file
@@ -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"
|
||||||
47
deploy/nginx/conf.d/bls_project_console.conf
Normal file
47
deploy/nginx/conf.d/bls_project_console.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
deploy/systemd/bls-project-console.service
Normal file
23
deploy/systemd/bls-project-console.service
Normal file
@@ -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
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
**描述**: 统一存储所有项目的心跳信息,替代原有的分散键结构。
|
**描述**: 统一存储所有项目的心跳信息,替代原有的分散键结构。
|
||||||
|
|
||||||
**数据格式**:
|
**数据格式**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -27,19 +26,11 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**字段说明**:
|
**字段说明**:
|
||||||
|
|
||||||
- `projectName`: 项目名称(字符串)
|
- `projectName`: 项目名称(字符串)
|
||||||
- `apiBaseUrl`: 项目API基础URL(字符串),对应“API地址”
|
- `apiBaseUrl`: 项目API基础URL(字符串)
|
||||||
- `lastActiveAt`: 最新心跳时间(Unix 时间戳毫秒,数字),对应“最新心跳时间”
|
- `lastActiveAt`: 最后活跃时间戳(毫秒,数字)
|
||||||
|
|
||||||
**与需求字段对应**:
|
|
||||||
|
|
||||||
- 项目名称 → `projectName`
|
|
||||||
- API地址 → `apiBaseUrl`
|
|
||||||
- 最新心跳时间 → `lastActiveAt`
|
|
||||||
|
|
||||||
**示例**:
|
**示例**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -66,7 +57,6 @@
|
|||||||
**数据格式**: 每个列表元素是一个JSON字符串,包含日志信息。
|
**数据格式**: 每个列表元素是一个JSON字符串,包含日志信息。
|
||||||
|
|
||||||
**日志对象格式**:
|
**日志对象格式**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
@@ -78,7 +68,6 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**字段说明**:
|
**字段说明**:
|
||||||
|
|
||||||
- `id`: 日志唯一标识符
|
- `id`: 日志唯一标识符
|
||||||
- `timestamp`: ISO-8601格式的时间戳
|
- `timestamp`: ISO-8601格式的时间戳
|
||||||
- `level`: 日志级别(info, warn, error, debug)
|
- `level`: 日志级别(info, warn, error, debug)
|
||||||
@@ -86,7 +75,6 @@
|
|||||||
- `metadata`: 可选的附加元数据
|
- `metadata`: 可选的附加元数据
|
||||||
|
|
||||||
**示例**:
|
**示例**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "log-1704067200000-abc123",
|
"id": "log-1704067200000-abc123",
|
||||||
@@ -109,7 +97,6 @@
|
|||||||
**描述**: 存储发送给项目的控制指令。
|
**描述**: 存储发送给项目的控制指令。
|
||||||
|
|
||||||
**指令对象格式**:
|
**指令对象格式**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"commandId": "string",
|
"commandId": "string",
|
||||||
@@ -123,7 +110,6 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**字段说明**:
|
**字段说明**:
|
||||||
|
|
||||||
- `commandId`: 指令唯一标识符
|
- `commandId`: 指令唯一标识符
|
||||||
- `timestamp`: ISO-8601格式的时间戳
|
- `timestamp`: ISO-8601格式的时间戳
|
||||||
- `source`: 指令来源(如"BLS Project Console")
|
- `source`: 指令来源(如"BLS Project Console")
|
||||||
@@ -133,7 +119,6 @@
|
|||||||
- `extraArgs`: 可选的额外参数
|
- `extraArgs`: 可选的额外参数
|
||||||
|
|
||||||
**示例**:
|
**示例**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"commandId": "cmd-1704067200000-xyz789",
|
"commandId": "cmd-1704067200000-xyz789",
|
||||||
@@ -171,7 +156,6 @@
|
|||||||
- 备份现有数据(可选)
|
- 备份现有数据(可选)
|
||||||
|
|
||||||
2. **执行迁移**
|
2. **执行迁移**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { migrateHeartbeatData } from './services/migrateHeartbeatData.js';
|
import { migrateHeartbeatData } from './services/migrateHeartbeatData.js';
|
||||||
|
|
||||||
@@ -195,7 +179,6 @@
|
|||||||
**端点**: `POST /api/projects/migrate`
|
**端点**: `POST /api/projects/migrate`
|
||||||
|
|
||||||
**请求体**:
|
**请求体**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"deleteOldKeys": false,
|
"deleteOldKeys": false,
|
||||||
@@ -204,12 +187,10 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**参数说明**:
|
**参数说明**:
|
||||||
|
|
||||||
- `deleteOldKeys`: 是否删除旧的心跳键(默认: false)
|
- `deleteOldKeys`: 是否删除旧的心跳键(默认: false)
|
||||||
- `dryRun`: 是否为干运行模式(默认: false)
|
- `dryRun`: 是否为干运行模式(默认: false)
|
||||||
|
|
||||||
**响应**:
|
**响应**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -251,7 +232,6 @@
|
|||||||
### 健康检查
|
### 健康检查
|
||||||
|
|
||||||
定期检查以下指标:
|
定期检查以下指标:
|
||||||
|
|
||||||
- Redis连接状态
|
- Redis连接状态
|
||||||
- 项目列表完整性
|
- 项目列表完整性
|
||||||
- 日志队列长度
|
- 日志队列长度
|
||||||
|
|||||||
@@ -2,8 +2,28 @@
|
|||||||
|
|
||||||
本文档定义“外部项目 ↔ BLS Project Console”之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**。
|
本文档定义“外部项目 ↔ 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 实例中:
|
> 约束:每个需要关联本控制台的外部项目,必须在本项目使用的同一个 Redis 实例中:
|
||||||
>
|
|
||||||
> - 写入 2 个 Key(心跳 + 控制台信息)
|
> - 写入 2 个 Key(心跳 + 控制台信息)
|
||||||
> - 命令下发为 HTTP API 调用
|
> - 命令下发为 HTTP API 调用
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
# Change: Refactor Project Heartbeat Data Structure
|
# Change: Refactor Project Heartbeat Data Structure
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
当前项目心跳数据使用分散的Redis键结构(`{projectName}_项目心跳`),导致以下问题:
|
当前项目心跳数据使用分散的Redis键结构(`{projectName}_项目心跳`),导致以下问题:
|
||||||
|
|
||||||
1. 难以统一管理和查询所有项目
|
1. 难以统一管理和查询所有项目
|
||||||
2. 前端项目选择功能需要硬编码测试数据
|
2. 前端项目选择功能需要硬编码测试数据
|
||||||
3. 无法高效获取项目列表和状态
|
3. 无法高效获取项目列表和状态
|
||||||
4. 数据迁移和维护成本高
|
4. 数据迁移和维护成本高
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- **新增**统一的项目列表Redis键:`项目心跳`
|
- **新增**统一的项目列表Redis键:`项目心跳`
|
||||||
- **新增**数据迁移工具,支持从旧结构迁移到新结构
|
- **新增**数据迁移工具,支持从旧结构迁移到新结构
|
||||||
- **新增**项目列表API端点:`GET /api/projects`
|
- **新增**项目列表API端点:`GET /api/projects`
|
||||||
@@ -20,7 +17,6 @@
|
|||||||
- **更新**OpenSpec规范文档,反映新的API和数据结构
|
- **更新**OpenSpec规范文档,反映新的API和数据结构
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
- Affected specs:
|
- Affected specs:
|
||||||
- `specs/logging/spec.md` - 更新日志API响应格式
|
- `specs/logging/spec.md` - 更新日志API响应格式
|
||||||
- `specs/command/spec.md` - 新增项目列表和迁移API
|
- `specs/command/spec.md` - 新增项目列表和迁移API
|
||||||
@@ -41,7 +37,6 @@
|
|||||||
- `src/frontend/App.vue` - 修正健康检查端点
|
- `src/frontend/App.vue` - 修正健康检查端点
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
1. 执行数据迁移:调用`POST /api/projects/migrate`
|
1. 执行数据迁移:调用`POST /api/projects/migrate`
|
||||||
2. 验证迁移结果:检查`项目心跳`键包含所有项目
|
2. 验证迁移结果:检查`项目心跳`键包含所有项目
|
||||||
3. 测试项目选择功能:确认前端能正确显示项目列表
|
3. 测试项目选择功能:确认前端能正确显示项目列表
|
||||||
@@ -49,15 +44,12 @@
|
|||||||
5. 清理旧键(可选):调用迁移API并设置`deleteOldKeys: true`
|
5. 清理旧键(可选):调用迁移API并设置`deleteOldKeys: true`
|
||||||
|
|
||||||
## Backward Compatibility
|
## Backward Compatibility
|
||||||
|
|
||||||
系统保持向后兼容:
|
系统保持向后兼容:
|
||||||
|
|
||||||
- 优先读取新的项目列表结构
|
- 优先读取新的项目列表结构
|
||||||
- 如果新结构中未找到项目,回退到旧结构
|
- 如果新结构中未找到项目,回退到旧结构
|
||||||
- 支持平滑过渡,无需立即删除旧键
|
- 支持平滑过渡,无需立即删除旧键
|
||||||
|
|
||||||
## Benefits
|
## Benefits
|
||||||
|
|
||||||
- 统一的项目管理,提高可维护性
|
- 统一的项目管理,提高可维护性
|
||||||
- 前端显示真实项目数据,移除测试假数据
|
- 前端显示真实项目数据,移除测试假数据
|
||||||
- 提高查询效率,减少Redis操作次数
|
- 提高查询效率,减少Redis操作次数
|
||||||
|
|||||||
@@ -1,50 +1,41 @@
|
|||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: Project List Management
|
### Requirement: Project List Management
|
||||||
|
|
||||||
The system SHALL provide a unified project list structure in Redis to manage all project heartbeats.
|
The system SHALL provide a unified project list structure in Redis to manage all project heartbeats.
|
||||||
|
|
||||||
#### Scenario: Getting all projects
|
#### Scenario: Getting all projects
|
||||||
|
|
||||||
- **WHEN** the user requests the project list
|
- **WHEN** the user requests the project list
|
||||||
- **THEN** the system SHALL return all projects with their heartbeat status
|
- **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
|
- **AND** each project SHALL include: project name, API base URL, last active time, and online status
|
||||||
|
|
||||||
#### Scenario: Project status calculation
|
#### Scenario: Project status calculation
|
||||||
|
|
||||||
- **WHEN** calculating project status
|
- **WHEN** calculating project status
|
||||||
- **THEN** the system SHALL determine online/offline based on last active time and offline threshold
|
- **THEN** the system SHALL determine online/offline based on last active time and offline threshold
|
||||||
- **AND** the system SHALL return the age in milliseconds
|
- **AND** the system SHALL return the age in milliseconds
|
||||||
|
|
||||||
### Requirement: Data Migration Support
|
### Requirement: Data Migration Support
|
||||||
|
|
||||||
The system SHALL support migrating heartbeat data from old structure to new unified structure.
|
The system SHALL support migrating heartbeat data from old structure to new unified structure.
|
||||||
|
|
||||||
#### Scenario: Migrating heartbeat data
|
#### Scenario: Migrating heartbeat data
|
||||||
|
|
||||||
- **WHEN** the migration process is triggered
|
- **WHEN** the migration process is triggered
|
||||||
- **THEN** the system SHALL read all old heartbeat keys
|
- **THEN** the system SHALL read all old heartbeat keys
|
||||||
- **AND** convert them to the new unified list structure
|
- **AND** convert them to the new unified list structure
|
||||||
- **AND** optionally delete old keys after successful migration
|
- **AND** optionally delete old keys after successful migration
|
||||||
|
|
||||||
#### Scenario: Dry run migration
|
#### Scenario: Dry run migration
|
||||||
|
|
||||||
- **WHEN** migration is executed with dryRun flag
|
- **WHEN** migration is executed with dryRun flag
|
||||||
- **THEN** the system SHALL validate data without writing
|
- **THEN** the system SHALL validate data without writing
|
||||||
- **AND** return the migration result for review
|
- **AND** return the migration result for review
|
||||||
|
|
||||||
### Requirement: Backward Compatibility
|
### Requirement: Backward Compatibility
|
||||||
|
|
||||||
The system SHALL maintain backward compatibility with old heartbeat data structure.
|
The system SHALL maintain backward compatibility with old heartbeat data structure.
|
||||||
|
|
||||||
#### Scenario: Reading from new structure
|
#### Scenario: Reading from new structure
|
||||||
|
|
||||||
- **WHEN** reading project heartbeat
|
- **WHEN** reading project heartbeat
|
||||||
- **THEN** the system SHALL first try to read from the new unified list
|
- **THEN** the system SHALL first try to read from the new unified list
|
||||||
- **AND** fall back to old structure if not found
|
- **AND** fall back to old structure if not found
|
||||||
|
|
||||||
#### Scenario: Gradual migration
|
#### Scenario: Gradual migration
|
||||||
|
|
||||||
- **WHEN** old structure data is detected
|
- **WHEN** old structure data is detected
|
||||||
- **THEN** the system SHALL continue to work with old data
|
- **THEN** the system SHALL continue to work with old data
|
||||||
- **AND** allow migration at a later time
|
- **AND** allow migration at a later time
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
## 1. Redis数据结构重构
|
## 1. Redis数据结构重构
|
||||||
|
|
||||||
- [x] 1.1 在redisKeys.js中添加projectsListKey()函数
|
- [x] 1.1 在redisKeys.js中添加projectsListKey()函数
|
||||||
- [x] 1.2 创建数据迁移工具migrateHeartbeatData.js
|
- [x] 1.2 创建数据迁移工具migrateHeartbeatData.js
|
||||||
- [x] 1.3 实现从分散键到统一列表的迁移逻辑
|
- [x] 1.3 实现从分散键到统一列表的迁移逻辑
|
||||||
@@ -7,7 +6,6 @@
|
|||||||
- [x] 1.5 实现updateProjectHeartbeat()函数
|
- [x] 1.5 实现updateProjectHeartbeat()函数
|
||||||
|
|
||||||
## 2. 后端API开发
|
## 2. 后端API开发
|
||||||
|
|
||||||
- [x] 2.1 创建项目列表API routes/projects.js
|
- [x] 2.1 创建项目列表API routes/projects.js
|
||||||
- [x] 2.2 实现GET /api/projects端点
|
- [x] 2.2 实现GET /api/projects端点
|
||||||
- [x] 2.3 实现POST /api/projects/migrate端点
|
- [x] 2.3 实现POST /api/projects/migrate端点
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
- [x] 2.6 在server.js中注册项目列表路由
|
- [x] 2.6 在server.js中注册项目列表路由
|
||||||
|
|
||||||
## 3. 前端代码修改
|
## 3. 前端代码修改
|
||||||
|
|
||||||
- [x] 3.1 更新ProjectSelector.vue移除假数据
|
- [x] 3.1 更新ProjectSelector.vue移除假数据
|
||||||
- [x] 3.2 实现从API获取项目列表
|
- [x] 3.2 实现从API获取项目列表
|
||||||
- [x] 3.3 实现项目状态显示
|
- [x] 3.3 实现项目状态显示
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
- [x] 3.10 修正App.vue健康检查端点
|
- [x] 3.10 修正App.vue健康检查端点
|
||||||
|
|
||||||
## 4. 文档更新
|
## 4. 文档更新
|
||||||
|
|
||||||
- [x] 4.1 创建Redis数据结构文档
|
- [x] 4.1 创建Redis数据结构文档
|
||||||
- [x] 4.2 更新logging OpenSpec规范
|
- [x] 4.2 更新logging OpenSpec规范
|
||||||
- [x] 4.3 更新command OpenSpec规范
|
- [x] 4.3 更新command OpenSpec规范
|
||||||
@@ -38,14 +34,12 @@
|
|||||||
- [x] 4.6 创建OpenSpec变更提案tasks.md
|
- [x] 4.6 创建OpenSpec变更提案tasks.md
|
||||||
|
|
||||||
## 5. 测试开发
|
## 5. 测试开发
|
||||||
|
|
||||||
- [ ] 5.1 编写数据迁移单元测试
|
- [ ] 5.1 编写数据迁移单元测试
|
||||||
- [ ] 5.2 编写项目列表API集成测试
|
- [ ] 5.2 编写项目列表API集成测试
|
||||||
- [ ] 5.3 编写ProjectSelector组件测试
|
- [ ] 5.3 编写ProjectSelector组件测试
|
||||||
- [ ] 5.4 编写性能测试
|
- [ ] 5.4 编写性能测试
|
||||||
|
|
||||||
## 6. 代码质量与验证
|
## 6. 代码质量与验证
|
||||||
|
|
||||||
- [ ] 6.1 运行ESLint检查
|
- [ ] 6.1 运行ESLint检查
|
||||||
- [ ] 6.2 运行Prettier格式化
|
- [ ] 6.2 运行Prettier格式化
|
||||||
- [ ] 6.3 验证项目选择功能
|
- [ ] 6.3 验证项目选择功能
|
||||||
|
|||||||
@@ -1,54 +1,44 @@
|
|||||||
# Command Capability Specification
|
# Command Capability Specification
|
||||||
|
|
||||||
## Overview
|
## 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.
|
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
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Command Sending to Redis
|
### Requirement: Command Sending to Redis
|
||||||
|
|
||||||
The system SHALL send commands to a Redis queue.
|
The system SHALL send commands to a Redis queue.
|
||||||
|
|
||||||
#### Scenario: Sending a command to Redis queue
|
#### Scenario: Sending a command to Redis queue
|
||||||
|
|
||||||
- **WHEN** the user enters a command in the console
|
- **WHEN** the user enters a command in the console
|
||||||
- **AND** clicks the "Send" button
|
- **AND** clicks the "Send" button
|
||||||
- **THEN** the command SHALL be sent to the Redis queue
|
- **THEN** the command SHALL be sent to the Redis queue
|
||||||
- **AND** the user SHALL receive a success confirmation
|
- **AND** the user SHALL receive a success confirmation
|
||||||
|
|
||||||
### Requirement: Command Validation
|
### Requirement: Command Validation
|
||||||
|
|
||||||
The system SHALL validate commands before sending them to Redis.
|
The system SHALL validate commands before sending them to Redis.
|
||||||
|
|
||||||
#### Scenario: Validating an empty command
|
#### Scenario: Validating an empty command
|
||||||
|
|
||||||
- **WHEN** the user tries to send an empty command
|
- **WHEN** the user tries to send an empty command
|
||||||
- **THEN** the system SHALL display an error message
|
- **THEN** the system SHALL display an error message
|
||||||
- **AND** the command SHALL NOT be sent to Redis
|
- **AND** the command SHALL NOT be sent to Redis
|
||||||
|
|
||||||
#### Scenario: Validating a command with invalid characters
|
#### Scenario: Validating a command with invalid characters
|
||||||
|
|
||||||
- **WHEN** the user tries to send a command with invalid characters
|
- **WHEN** the user tries to send a command with invalid characters
|
||||||
- **THEN** the system SHALL display an error message
|
- **THEN** the system SHALL display an error message
|
||||||
- **AND** the command SHALL NOT be sent to Redis
|
- **AND** the command SHALL NOT be sent to Redis
|
||||||
|
|
||||||
### Requirement: Command History
|
### Requirement: Command History
|
||||||
|
|
||||||
The system SHALL maintain a history of sent commands.
|
The system SHALL maintain a history of sent commands.
|
||||||
|
|
||||||
#### Scenario: Viewing command history
|
#### Scenario: Viewing command history
|
||||||
|
|
||||||
- **WHEN** the user opens the command history
|
- **WHEN** the user opens the command history
|
||||||
- **THEN** the system SHALL display a list of previously sent commands
|
- **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
|
- **AND** the user SHALL be able to select a command from the history to resend
|
||||||
|
|
||||||
### Requirement: Command Response Handling
|
### Requirement: Command Response Handling
|
||||||
|
|
||||||
The system SHALL handle responses from commands sent to Redis.
|
The system SHALL handle responses from commands sent to Redis.
|
||||||
|
|
||||||
#### Scenario: Receiving a command response
|
#### Scenario: Receiving a command response
|
||||||
|
|
||||||
- **WHEN** a command response is received from Redis
|
- **WHEN** a command response is received from Redis
|
||||||
- **THEN** the system SHALL display the response in the console
|
- **THEN** the system SHALL display the response in the console
|
||||||
- **AND** the response SHALL be associated with the original command
|
- **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
|
## Data Model
|
||||||
|
|
||||||
### Command
|
### Command
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
@@ -67,7 +56,6 @@ The system SHALL handle responses from commands sent to Redis.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Command Response
|
### Command Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
@@ -81,7 +69,6 @@ The system SHALL handle responses from commands sent to Redis.
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### POST /api/commands
|
### POST /api/commands
|
||||||
|
|
||||||
- **Description**: Send a command to a project's API endpoint
|
- **Description**: Send a command to a project's API endpoint
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
@@ -103,7 +90,6 @@ The system SHALL handle responses from commands sent to Redis.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/projects
|
### GET /api/projects
|
||||||
|
|
||||||
- **Description**: Get list of all projects with their heartbeat status
|
- **Description**: Get list of all projects with their heartbeat status
|
||||||
- **Response**:
|
- **Response**:
|
||||||
```json
|
```json
|
||||||
@@ -125,7 +111,6 @@ The system SHALL handle responses from commands sent to Redis.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/projects/migrate
|
### POST /api/projects/migrate
|
||||||
|
|
||||||
- **Description**: Migrate heartbeat data from old structure to new unified structure
|
- **Description**: Migrate heartbeat data from old structure to new unified structure
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
@@ -147,7 +132,6 @@ The system SHALL handle responses from commands sent to Redis.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/commands/history
|
### GET /api/commands/history
|
||||||
|
|
||||||
- **Description**: Get command history (deprecated - use project logs instead)
|
- **Description**: Get command history (deprecated - use project logs instead)
|
||||||
- **Query Parameters**:
|
- **Query Parameters**:
|
||||||
- `limit`: Maximum number of commands to return (default: 50)
|
- `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
|
- **Response**: Array of command objects
|
||||||
|
|
||||||
### GET /api/commands/:id/response
|
### GET /api/commands/:id/response
|
||||||
|
|
||||||
- **Description**: Get response for a specific command (deprecated - use project logs instead)
|
- **Description**: Get response for a specific command (deprecated - use project logs instead)
|
||||||
- **Response**: Command response object
|
- **Response**: Command response object
|
||||||
|
|||||||
@@ -1,53 +1,43 @@
|
|||||||
# Logging Capability Specification
|
# Logging Capability Specification
|
||||||
|
|
||||||
## Overview
|
## 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.
|
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
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Log Reading from Redis
|
### Requirement: Log Reading from Redis
|
||||||
|
|
||||||
The system SHALL read log records from a Redis queue.
|
The system SHALL read log records from a Redis queue.
|
||||||
|
|
||||||
#### Scenario: Reading logs from Redis queue
|
#### Scenario: Reading logs from Redis queue
|
||||||
|
|
||||||
- **WHEN** the server starts
|
- **WHEN** the server starts
|
||||||
- **THEN** it SHALL establish a connection to the Redis queue
|
- **THEN** it SHALL establish a connection to the Redis queue
|
||||||
- **AND** it SHALL begin listening for new log records
|
- **AND** it SHALL begin listening for new log records
|
||||||
- **AND** it SHALL store log records in memory for display
|
- **AND** it SHALL store log records in memory for display
|
||||||
|
|
||||||
### Requirement: Log Display in Console
|
### Requirement: Log Display in Console
|
||||||
|
|
||||||
The system SHALL display log records in a user-friendly format.
|
The system SHALL display log records in a user-friendly format.
|
||||||
|
|
||||||
#### Scenario: Displaying logs in the console
|
#### Scenario: Displaying logs in the console
|
||||||
|
|
||||||
- **WHEN** a log record is received from Redis
|
- **WHEN** a log record is received from Redis
|
||||||
- **THEN** it SHALL be added to the log list in the console
|
- **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 display the log timestamp, level, and message
|
||||||
- **AND** it SHALL support scrolling through historical logs
|
- **AND** it SHALL support scrolling through historical logs
|
||||||
|
|
||||||
### Requirement: Log Filtering
|
### Requirement: Log Filtering
|
||||||
|
|
||||||
The system SHALL allow users to filter logs by level and time range.
|
The system SHALL allow users to filter logs by level and time range.
|
||||||
|
|
||||||
#### Scenario: Filtering logs by level
|
#### Scenario: Filtering logs by level
|
||||||
|
|
||||||
- **WHEN** the user selects a log level filter
|
- **WHEN** the user selects a log level filter
|
||||||
- **THEN** only logs with the selected level SHALL be displayed
|
- **THEN** only logs with the selected level SHALL be displayed
|
||||||
|
|
||||||
#### Scenario: Filtering logs by time range
|
#### Scenario: Filtering logs by time range
|
||||||
|
|
||||||
- **WHEN** the user selects a time range
|
- **WHEN** the user selects a time range
|
||||||
- **THEN** only logs within the specified range SHALL be displayed
|
- **THEN** only logs within the specified range SHALL be displayed
|
||||||
|
|
||||||
### Requirement: Log Auto-Refresh
|
### Requirement: Log Auto-Refresh
|
||||||
|
|
||||||
The system SHALL automatically refresh logs in real-time.
|
The system SHALL automatically refresh logs in real-time.
|
||||||
|
|
||||||
#### Scenario: Real-time log updates
|
#### Scenario: Real-time log updates
|
||||||
|
|
||||||
- **WHEN** a new log is added to the Redis queue
|
- **WHEN** a new log is added to the Redis queue
|
||||||
- **THEN** it SHALL be automatically displayed in the console
|
- **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
|
- **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
|
## Data Model
|
||||||
|
|
||||||
### Log Record
|
### Log Record
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
@@ -69,7 +58,6 @@ The system SHALL automatically refresh logs in real-time.
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### GET /api/logs
|
### GET /api/logs
|
||||||
|
|
||||||
- **Description**: Get log records for a specific project
|
- **Description**: Get log records for a specific project
|
||||||
- **Query Parameters**:
|
- **Query Parameters**:
|
||||||
- `projectName`: Project name (required)
|
- `projectName`: Project name (required)
|
||||||
@@ -97,6 +85,5 @@ The system SHALL automatically refresh logs in real-time.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/logs/live
|
### GET /api/logs/live
|
||||||
|
|
||||||
- **Description**: Establish a WebSocket connection for real-time log updates
|
- **Description**: Establish a WebSocket connection for real-time log updates
|
||||||
- **Response**: Continuous stream of log records
|
- **Response**: Continuous stream of log records
|
||||||
|
|||||||
@@ -1,59 +1,48 @@
|
|||||||
# Redis Connection Capability Specification
|
# Redis Connection Capability Specification
|
||||||
|
|
||||||
## Overview
|
## 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.
|
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
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Redis Connection Establishment
|
### Requirement: Redis Connection Establishment
|
||||||
|
|
||||||
The system SHALL establish a connection to the Redis server.
|
The system SHALL establish a connection to the Redis server.
|
||||||
|
|
||||||
#### Scenario: Establishing Redis connection on server start
|
#### Scenario: Establishing Redis connection on server start
|
||||||
|
|
||||||
- **WHEN** the server starts
|
- **WHEN** the server starts
|
||||||
- **THEN** it SHALL attempt to connect to the Redis server
|
- **THEN** it SHALL attempt to connect to the Redis server
|
||||||
- **AND** it SHALL log the connection status
|
- **AND** it SHALL log the connection status
|
||||||
|
|
||||||
### Requirement: Redis Connection Configuration
|
### Requirement: Redis Connection Configuration
|
||||||
|
|
||||||
The system SHALL allow configuration of Redis connection parameters.
|
The system SHALL allow configuration of Redis connection parameters.
|
||||||
|
|
||||||
#### Scenario: Configuring Redis connection via environment variables
|
#### Scenario: Configuring Redis connection via environment variables
|
||||||
|
|
||||||
- **WHEN** the server starts with Redis environment variables set
|
- **WHEN** the server starts with Redis environment variables set
|
||||||
- **THEN** it SHALL use those variables to configure the Redis connection
|
- **THEN** it SHALL use those variables to configure the Redis connection
|
||||||
- **AND** it SHALL override default values
|
- **AND** it SHALL override default values
|
||||||
|
|
||||||
### Requirement: Redis Connection Reconnection
|
### Requirement: Redis Connection Reconnection
|
||||||
|
|
||||||
The system SHALL automatically reconnect to Redis if the connection is lost.
|
The system SHALL automatically reconnect to Redis if the connection is lost.
|
||||||
|
|
||||||
#### Scenario: Reconnecting to Redis after connection loss
|
#### Scenario: Reconnecting to Redis after connection loss
|
||||||
|
|
||||||
- **WHEN** the Redis connection is lost
|
- **WHEN** the Redis connection is lost
|
||||||
- **THEN** the system SHALL attempt to reconnect with exponential backoff
|
- **THEN** the system SHALL attempt to reconnect with exponential backoff
|
||||||
- **AND** it SHALL log each reconnection attempt
|
- **AND** it SHALL log each reconnection attempt
|
||||||
- **AND** it SHALL notify the user when connection is restored
|
- **AND** it SHALL notify the user when connection is restored
|
||||||
|
|
||||||
### Requirement: Redis Connection Error Handling
|
### Requirement: Redis Connection Error Handling
|
||||||
|
|
||||||
The system SHALL handle Redis connection errors gracefully.
|
The system SHALL handle Redis connection errors gracefully.
|
||||||
|
|
||||||
#### Scenario: Handling Redis connection failure
|
#### Scenario: Handling Redis connection failure
|
||||||
|
|
||||||
- **WHEN** the system fails to connect to Redis
|
- **WHEN** the system fails to connect to Redis
|
||||||
- **THEN** it SHALL log the error
|
- **THEN** it SHALL log the error
|
||||||
- **AND** it SHALL display an error message to the user
|
- **AND** it SHALL display an error message to the user
|
||||||
- **AND** it SHALL continue attempting to reconnect
|
- **AND** it SHALL continue attempting to reconnect
|
||||||
|
|
||||||
### Requirement: Redis Connection Monitoring
|
### Requirement: Redis Connection Monitoring
|
||||||
|
|
||||||
The system SHALL monitor the Redis connection status.
|
The system SHALL monitor the Redis connection status.
|
||||||
|
|
||||||
#### Scenario: Monitoring Redis connection status
|
#### Scenario: Monitoring Redis connection status
|
||||||
|
|
||||||
- **WHEN** the Redis connection status changes
|
- **WHEN** the Redis connection status changes
|
||||||
- **THEN** the system SHALL update the connection status in the UI
|
- **THEN** the system SHALL update the connection status in the UI
|
||||||
- **AND** it SHALL log the status change
|
- **AND** it SHALL log the status change
|
||||||
@@ -61,7 +50,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
### Redis Connection Configuration
|
### Redis Connection Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"host": "string",
|
"host": "string",
|
||||||
@@ -77,7 +65,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Redis Connection Status
|
### Redis Connection Status
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "string", // e.g., connecting, connected, disconnected, error
|
"status": "string", // e.g., connecting, connected, disconnected, error
|
||||||
@@ -90,7 +77,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### GET /api/redis/status
|
### GET /api/redis/status
|
||||||
|
|
||||||
- **Description**: Get Redis connection status
|
- **Description**: Get Redis connection status
|
||||||
- **Response**:
|
- **Response**:
|
||||||
```json
|
```json
|
||||||
@@ -103,7 +89,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/redis/reconnect
|
### POST /api/redis/reconnect
|
||||||
|
|
||||||
- **Description**: Manually reconnect to Redis
|
- **Description**: Manually reconnect to Redis
|
||||||
- **Response**:
|
- **Response**:
|
||||||
```json
|
```json
|
||||||
@@ -114,7 +99,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/projects
|
### GET /api/projects
|
||||||
|
|
||||||
- **Description**: Get list of all projects from Redis
|
- **Description**: Get list of all projects from Redis
|
||||||
- **Response**:
|
- **Response**:
|
||||||
```json
|
```json
|
||||||
@@ -136,7 +120,6 @@ The system SHALL monitor the Redis connection status.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/projects/migrate
|
### POST /api/projects/migrate
|
||||||
|
|
||||||
- **Description**: Migrate heartbeat data from old structure to new unified structure
|
- **Description**: Migrate heartbeat data from old structure to new unified structure
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -53,15 +53,14 @@ function buildTargetUrl(apiBaseUrl, apiName) {
|
|||||||
|
|
||||||
function truncateForLog(value, maxLen = 2000) {
|
function truncateForLog(value, maxLen = 2000) {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
if (typeof value === 'string')
|
if (typeof value === 'string') return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectHeartbeat(redis, projectName) {
|
async function getProjectHeartbeat(redis, projectName) {
|
||||||
try {
|
try {
|
||||||
const projectsList = await getProjectsList(redis);
|
const projectsList = await getProjectsList();
|
||||||
const project = projectsList.find((p) => p.projectName === projectName);
|
const project = projectsList.find(p => p.projectName === projectName);
|
||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
return {
|
return {
|
||||||
@@ -70,10 +69,7 @@ async function getProjectHeartbeat(redis, projectName) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
|
||||||
'[getProjectHeartbeat] Failed to get from projects list:',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
||||||
@@ -137,10 +133,7 @@ router.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
|
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
|
||||||
const heartbeat = await getProjectHeartbeat(
|
const heartbeat = await getProjectHeartbeat(redis, targetProjectName.trim());
|
||||||
redis,
|
|
||||||
targetProjectName.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!heartbeat) {
|
if (!heartbeat) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
@@ -149,10 +142,7 @@ router.post('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBaseUrl =
|
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl.trim() : '';
|
||||||
typeof heartbeat.apiBaseUrl === 'string'
|
|
||||||
? heartbeat.apiBaseUrl.trim()
|
|
||||||
: '';
|
|
||||||
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
||||||
|
|
||||||
if (!apiBaseUrl) {
|
if (!apiBaseUrl) {
|
||||||
@@ -162,16 +152,9 @@ router.post('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const offlineThresholdMs = Number.parseInt(
|
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
|
||||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (!lastActiveAt || (Number.isFinite(offlineThresholdMs) && now - lastActiveAt > offlineThresholdMs)) {
|
||||||
!lastActiveAt ||
|
|
||||||
(Number.isFinite(offlineThresholdMs) &&
|
|
||||||
now - lastActiveAt > offlineThresholdMs)
|
|
||||||
) {
|
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '目标项目已离线(心跳超时)',
|
message: '目标项目已离线(心跳超时)',
|
||||||
@@ -187,10 +170,7 @@ router.post('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = Number.parseInt(
|
const timeoutMs = Number.parseInt(process.env.COMMAND_API_TIMEOUT_MS || '5000', 10);
|
||||||
process.env.COMMAND_API_TIMEOUT_MS || '5000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const resp = await axios.post(targetUrl, payload, {
|
const resp = await axios.post(targetUrl, payload, {
|
||||||
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
|
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
|
||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import express from 'express';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
import { getRedisClient } from '../services/redisClient.js';
|
import { getRedisClient } from '../services/redisClient.js';
|
||||||
import {
|
import { projectConsoleKey, projectHeartbeatKey } from '../services/redisKeys.js';
|
||||||
projectConsoleKey,
|
|
||||||
projectHeartbeatKey,
|
|
||||||
} from '../services/redisKeys.js';
|
|
||||||
import { getProjectsList } from '../services/migrateHeartbeatData.js';
|
import { getProjectsList } from '../services/migrateHeartbeatData.js';
|
||||||
|
|
||||||
function parsePositiveInt(value, defaultValue) {
|
function parsePositiveInt(value, defaultValue) {
|
||||||
@@ -28,8 +25,8 @@ function parseLastActiveAt(value) {
|
|||||||
|
|
||||||
async function getProjectHeartbeat(redis, projectName) {
|
async function getProjectHeartbeat(redis, projectName) {
|
||||||
try {
|
try {
|
||||||
const projectsList = await getProjectsList(redis);
|
const projectsList = await getProjectsList();
|
||||||
const project = projectsList.find((p) => p.projectName === projectName);
|
const project = projectsList.find(p => p.projectName === projectName);
|
||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
return {
|
return {
|
||||||
@@ -38,10 +35,7 @@ async function getProjectHeartbeat(redis, projectName) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
|
||||||
'[getProjectHeartbeat] Failed to get from projects list:',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
||||||
@@ -58,10 +52,7 @@ async function getProjectHeartbeat(redis, projectName) {
|
|||||||
|
|
||||||
// 获取日志列表
|
// 获取日志列表
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const projectName =
|
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
|
||||||
typeof req.query.projectName === 'string'
|
|
||||||
? req.query.projectName.trim()
|
|
||||||
: '';
|
|
||||||
const limit = parsePositiveInt(req.query.limit, 200);
|
const limit = parsePositiveInt(req.query.limit, 200);
|
||||||
|
|
||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
@@ -84,61 +75,50 @@ router.get('/', async (req, res) => {
|
|||||||
const key = projectConsoleKey(projectName);
|
const key = projectConsoleKey(projectName);
|
||||||
const list = await redis.lRange(key, -limit, -1);
|
const list = await redis.lRange(key, -limit, -1);
|
||||||
|
|
||||||
const logs = list.map((raw, idx) => {
|
const logs = list
|
||||||
try {
|
.map((raw, idx) => {
|
||||||
const parsed = JSON.parse(raw);
|
try {
|
||||||
const timestamp = parsed.timestamp || new Date().toISOString();
|
const parsed = JSON.parse(raw);
|
||||||
const level = (parsed.level || 'info').toString().toLowerCase();
|
const timestamp = parsed.timestamp || new Date().toISOString();
|
||||||
const message = parsed.message != null ? String(parsed.message) : '';
|
const level = (parsed.level || 'info').toString().toLowerCase();
|
||||||
return {
|
const message = parsed.message != null ? String(parsed.message) : '';
|
||||||
id: parsed.id || `log-${timestamp}-${idx}`,
|
return {
|
||||||
timestamp,
|
id: parsed.id || `log-${timestamp}-${idx}`,
|
||||||
level,
|
timestamp,
|
||||||
message,
|
level,
|
||||||
metadata:
|
message,
|
||||||
parsed.metadata && typeof parsed.metadata === 'object'
|
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
|
||||||
? parsed.metadata
|
};
|
||||||
: undefined,
|
} catch {
|
||||||
};
|
return {
|
||||||
} catch {
|
id: `log-${Date.now()}-${idx}`,
|
||||||
return {
|
timestamp: new Date().toISOString(),
|
||||||
id: `log-${Date.now()}-${idx}`,
|
level: 'info',
|
||||||
timestamp: new Date().toISOString(),
|
message: raw,
|
||||||
level: 'info',
|
};
|
||||||
message: raw,
|
}
|
||||||
};
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
const heartbeat = await getProjectHeartbeat(redis, projectName);
|
const heartbeat = await getProjectHeartbeat(redis, projectName);
|
||||||
|
|
||||||
const offlineThresholdMs = Number.parseInt(
|
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
|
||||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
||||||
const ageMs = lastActiveAt ? now - lastActiveAt : null;
|
const ageMs = lastActiveAt ? now - lastActiveAt : null;
|
||||||
const isOnline =
|
const isOnline = lastActiveAt && Number.isFinite(offlineThresholdMs)
|
||||||
lastActiveAt && Number.isFinite(offlineThresholdMs)
|
? ageMs <= offlineThresholdMs
|
||||||
? ageMs <= offlineThresholdMs
|
: Boolean(lastActiveAt);
|
||||||
: Boolean(lastActiveAt);
|
|
||||||
|
|
||||||
const computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null;
|
const computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null;
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
logs,
|
logs,
|
||||||
projectStatus: computedStatus || null,
|
projectStatus: computedStatus || null,
|
||||||
heartbeat: heartbeat
|
heartbeat: heartbeat ? {
|
||||||
? {
|
apiBaseUrl: typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null,
|
||||||
apiBaseUrl:
|
lastActiveAt: lastActiveAt || null,
|
||||||
typeof heartbeat.apiBaseUrl === 'string'
|
isOnline,
|
||||||
? heartbeat.apiBaseUrl
|
ageMs,
|
||||||
: null,
|
} : null,
|
||||||
lastActiveAt: lastActiveAt || null,
|
|
||||||
isOnline,
|
|
||||||
ageMs,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to read logs', err);
|
console.error('Failed to read logs', err);
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import express from 'express';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
import { getRedisClient } from '../services/redisClient.js';
|
import { getRedisClient } from '../services/redisClient.js';
|
||||||
import {
|
import { migrateHeartbeatData, getProjectsList } from '../services/migrateHeartbeatData.js';
|
||||||
migrateHeartbeatData,
|
|
||||||
getProjectsList,
|
|
||||||
} from '../services/migrateHeartbeatData.js';
|
|
||||||
|
|
||||||
function parseLastActiveAt(value) {
|
function parseLastActiveAt(value) {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
@@ -20,10 +17,7 @@ function parseLastActiveAt(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function computeProjectStatus(project) {
|
function computeProjectStatus(project) {
|
||||||
const offlineThresholdMs = Number.parseInt(
|
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
|
||||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
|
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
|
||||||
|
|
||||||
@@ -36,9 +30,7 @@ function computeProjectStatus(project) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ageMs = now - lastActiveAt;
|
const ageMs = now - lastActiveAt;
|
||||||
const isOnline = Number.isFinite(offlineThresholdMs)
|
const isOnline = Number.isFinite(offlineThresholdMs) ? ageMs <= offlineThresholdMs : true;
|
||||||
? ageMs <= offlineThresholdMs
|
|
||||||
: true;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: isOnline ? 'online' : 'offline',
|
status: isOnline ? 'online' : 'offline',
|
||||||
@@ -58,8 +50,8 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectsList = await getProjectsList(redis);
|
const projectsList = await getProjectsList();
|
||||||
const projects = projectsList.map((project) => {
|
const projects = projectsList.map(project => {
|
||||||
const statusInfo = computeProjectStatus(project);
|
const statusInfo = computeProjectStatus(project);
|
||||||
return {
|
return {
|
||||||
id: project.projectName,
|
id: project.projectName,
|
||||||
@@ -89,11 +81,9 @@ router.post('/migrate', async (req, res) => {
|
|||||||
const { deleteOldKeys = false, dryRun = false } = req.body;
|
const { deleteOldKeys = false, dryRun = false } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redis = req.app?.locals?.redis || (await getRedisClient());
|
|
||||||
const result = await migrateHeartbeatData({
|
const result = await migrateHeartbeatData({
|
||||||
deleteOldKeys: Boolean(deleteOldKeys),
|
deleteOldKeys: Boolean(deleteOldKeys),
|
||||||
dryRun: Boolean(dryRun),
|
dryRun: Boolean(dryRun),
|
||||||
redis,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|||||||
@@ -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 { getRedisClient } from './services/redisClient.js';
|
||||||
import { createApp } from './app.js';
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
const PORT = 3001;
|
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 () => {
|
const server = app.listen(PORT, async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getRedisClient } from './redisClient.js';
|
import { getRedisClient } from './redisClient.js';
|
||||||
import { projectsListKey } from './redisKeys.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 === 'number' && Number.isFinite(value)) return value;
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const asNum = Number(value);
|
const asNum = Number(value);
|
||||||
@@ -12,65 +12,13 @@ export function parseLastActiveAt(value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProjectEntry(entry) {
|
export async function migrateHeartbeatData(options = {}) {
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
const { deleteOldKeys = false, dryRun = false } = options;
|
||||||
|
|
||||||
const projectName =
|
const redis = await getRedisClient();
|
||||||
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());
|
|
||||||
if (!redis?.isReady) {
|
if (!redis?.isReady) {
|
||||||
throw new Error('Redis 未就绪');
|
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] 开始迁移心跳数据...');
|
console.log('[migrate] 开始迁移心跳数据...');
|
||||||
|
|
||||||
@@ -96,37 +44,38 @@ export async function migrateHeartbeatData(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectName = key.replace('_项目心跳', '');
|
const projectName = key.replace('_项目心跳', '');
|
||||||
const project = normalizeProjectEntry({
|
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null;
|
||||||
projectName,
|
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
||||||
apiBaseUrl: heartbeat?.apiBaseUrl,
|
|
||||||
lastActiveAt: heartbeat?.lastActiveAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project?.apiBaseUrl) {
|
if (!apiBaseUrl) {
|
||||||
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
|
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
projectName,
|
||||||
|
apiBaseUrl,
|
||||||
|
lastActiveAt: lastActiveAt || null,
|
||||||
|
};
|
||||||
|
|
||||||
projectsList.push(project);
|
projectsList.push(project);
|
||||||
console.log(`[migrate] 添加项目: ${projectName}`);
|
console.log(`[migrate] 添加项目: ${projectName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProjectsList = normalizeProjectsList(projectsList);
|
console.log(`[migrate] 共迁移 ${projectsList.length} 个项目`);
|
||||||
|
|
||||||
console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`);
|
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log('[migrate] 干运行模式,不写入数据');
|
console.log('[migrate] 干运行模式,不写入数据');
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
migrated: normalizedProjectsList.length,
|
migrated: projectsList.length,
|
||||||
projects: normalizedProjectsList,
|
projects: projectsList,
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const listKey = projectsListKey();
|
const listKey = projectsListKey();
|
||||||
await redis.set(listKey, JSON.stringify(normalizedProjectsList));
|
await redis.set(listKey, JSON.stringify(projectsList));
|
||||||
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
|
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
|
||||||
|
|
||||||
if (deleteOldKeys) {
|
if (deleteOldKeys) {
|
||||||
@@ -141,8 +90,8 @@ export async function migrateHeartbeatData(options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
migrated: normalizedProjectsList.length,
|
migrated: projectsList.length,
|
||||||
projects: normalizedProjectsList,
|
projects: projectsList,
|
||||||
listKey,
|
listKey,
|
||||||
deleteOldKeys,
|
deleteOldKeys,
|
||||||
};
|
};
|
||||||
@@ -152,8 +101,11 @@ export async function migrateHeartbeatData(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectsList(redisOverride) {
|
export async function getProjectsList() {
|
||||||
const redis = await getReadyRedis(redisOverride);
|
const redis = await getRedisClient();
|
||||||
|
if (!redis?.isReady) {
|
||||||
|
throw new Error('Redis 未就绪');
|
||||||
|
}
|
||||||
|
|
||||||
const listKey = projectsListKey();
|
const listKey = projectsListKey();
|
||||||
const raw = await redis.get(listKey);
|
const raw = await redis.get(listKey);
|
||||||
@@ -164,35 +116,28 @@ export async function getProjectsList(redisOverride) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const list = JSON.parse(raw);
|
const list = JSON.parse(raw);
|
||||||
return normalizeProjectsList(list);
|
return Array.isArray(list) ? list : [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[getProjectsList] 解析项目列表失败:', err);
|
console.error('[getProjectsList] 解析项目列表失败:', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProjectHeartbeat(
|
export async function updateProjectHeartbeat(projectName, heartbeatData) {
|
||||||
projectName,
|
const redis = await getRedisClient();
|
||||||
heartbeatData,
|
if (!redis?.isReady) {
|
||||||
redisOverride,
|
throw new Error('Redis 未就绪');
|
||||||
) {
|
|
||||||
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('无效的项目心跳数据');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (existingIndex >= 0) {
|
||||||
projectsList[existingIndex] = project;
|
projectsList[existingIndex] = project;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,46 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<button
|
<button v-if="isMobile" class="menu-toggle" @click="toggleSidebar">
|
||||||
v-if="isMobile"
|
|
||||||
class="menu-toggle"
|
|
||||||
@click="toggleSidebar"
|
|
||||||
>
|
|
||||||
<span class="menu-icon" />
|
<span class="menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
<h1>BLS Project Console</h1>
|
<h1>BLS Project Console</h1>
|
||||||
<div
|
<div class="service-status"
|
||||||
class="service-status"
|
:class="{ 'status-ok': serviceStatus === 'ok', 'status-error': serviceStatus === 'error' }">
|
||||||
:class="{
|
|
||||||
'status-ok': serviceStatus === 'ok',
|
|
||||||
'status-error': serviceStatus === 'error',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ serviceStatusText }}
|
{{ serviceStatusText }}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<!-- 左侧选择区域 -->
|
<!-- 左侧选择区域 -->
|
||||||
<aside
|
<aside class="sidebar" :class="{ 'sidebar-closed': !sidebarOpen && isMobile }">
|
||||||
class="sidebar"
|
|
||||||
:class="{ 'sidebar-closed': !sidebarOpen && isMobile }"
|
|
||||||
>
|
|
||||||
<router-view name="sidebar" />
|
<router-view name="sidebar" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 右侧调试区域 -->
|
<!-- 右侧调试区域 -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div
|
<div v-if="serviceStatus === 'error'" class="service-error-message">
|
||||||
v-if="serviceStatus === 'error'"
|
|
||||||
class="service-error-message"
|
|
||||||
>
|
|
||||||
<h3>服务不可用</h3>
|
<h3>服务不可用</h3>
|
||||||
<p>无法连接到后端服务,请检查服务是否正常运行。</p>
|
<p>无法连接到后端服务,请检查服务是否正常运行。</p>
|
||||||
</div>
|
</div>
|
||||||
<router-view
|
<router-view v-else name="main" />
|
||||||
v-else
|
|
||||||
name="main"
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +133,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background-color: #008c8c;
|
background-color: #008C8C;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -196,9 +178,7 @@ body {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
transition:
|
transition: transform 0.2s, top 0.2s;
|
||||||
transform 0.2s,
|
|
||||||
top 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-icon::before {
|
.menu-icon::before {
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
<!-- 日志级别筛选 -->
|
<!-- 日志级别筛选 -->
|
||||||
<div class="log-level-filter">
|
<div class="log-level-filter">
|
||||||
<label class="filter-label">日志级别:</label>
|
<label class="filter-label">日志级别:</label>
|
||||||
<select
|
<select v-model="selectedLogLevel" class="filter-select">
|
||||||
v-model="selectedLogLevel"
|
|
||||||
class="filter-select"
|
|
||||||
>
|
|
||||||
<option value="all">
|
<option value="all">
|
||||||
全部
|
全部
|
||||||
</option>
|
</option>
|
||||||
@@ -31,20 +28,13 @@
|
|||||||
<!-- 自动滚动开关 -->
|
<!-- 自动滚动开关 -->
|
||||||
<div class="auto-scroll-toggle">
|
<div class="auto-scroll-toggle">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input
|
<input v-model="autoScroll" type="checkbox" class="toggle-checkbox">
|
||||||
v-model="autoScroll"
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle-checkbox"
|
|
||||||
>
|
|
||||||
<span class="toggle-text">自动滚动</span>
|
<span class="toggle-text">自动滚动</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 日志清理按钮 -->
|
<!-- 日志清理按钮 -->
|
||||||
<button
|
<button class="clear-logs-btn" @click="clearLogs">
|
||||||
class="clear-logs-btn"
|
|
||||||
@click="clearLogs"
|
|
||||||
>
|
|
||||||
清空日志
|
清空日志
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -56,30 +46,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 日志显示区域 -->
|
<!-- 日志显示区域 -->
|
||||||
<div
|
<div ref="logsContainer" class="logs-container">
|
||||||
ref="logsContainer"
|
<div ref="logTableWrapper" class="log-table-wrapper" @scroll="handleScroll">
|
||||||
class="logs-container"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref="logTableWrapper"
|
|
||||||
class="log-table-wrapper"
|
|
||||||
@scroll="handleScroll"
|
|
||||||
>
|
|
||||||
<table class="log-table">
|
<table class="log-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="log in filteredLogs" :key="log.id" :class="`log-item log-level-${log.level}`">
|
||||||
v-for="log in filteredLogs"
|
|
||||||
:key="log.id"
|
|
||||||
:class="`log-item log-level-${log.level}`"
|
|
||||||
>
|
|
||||||
<td class="log-meta">
|
<td class="log-meta">
|
||||||
<div class="log-timestamp">
|
<div class="log-timestamp">
|
||||||
{{ formatTimestamp(log.timestamp) }}
|
{{ formatTimestamp(log.timestamp) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="log-level-badge" :class="`level-${log.level}`">
|
||||||
class="log-level-badge"
|
|
||||||
:class="`level-${log.level}`"
|
|
||||||
>
|
|
||||||
{{ log.level.toUpperCase() }}
|
{{ log.level.toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -92,10 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div v-if="filteredLogs.length === 0" class="empty-logs">
|
||||||
v-if="filteredLogs.length === 0"
|
|
||||||
class="empty-logs"
|
|
||||||
>
|
|
||||||
<p>暂无日志记录</p>
|
<p>暂无日志记录</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,19 +78,9 @@
|
|||||||
<div class="command-prompt">
|
<div class="command-prompt">
|
||||||
$
|
$
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input ref="commandInputRef" v-model="commandInput" type="text" class="command-input"
|
||||||
ref="commandInputRef"
|
placeholder="输入:<接口名> <参数...>(按空格分割)" autocomplete="off" @keydown.enter="sendCommand">
|
||||||
v-model="commandInput"
|
<button class="send-command-btn" @click="sendCommand">
|
||||||
type="text"
|
|
||||||
class="command-input"
|
|
||||||
placeholder="输入:<接口名> <参数...>(按空格分割)"
|
|
||||||
autocomplete="off"
|
|
||||||
@keydown.enter="sendCommand"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="send-command-btn"
|
|
||||||
@click="sendCommand"
|
|
||||||
>
|
|
||||||
发送
|
发送
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +113,7 @@ const isAtBottom = ref(true);
|
|||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
const mergedLogs = computed(() => {
|
const mergedLogs = computed(() => {
|
||||||
const combined = [ ...remoteLogs.value, ...uiLogs.value ]
|
const combined = [...remoteLogs.value, ...uiLogs.value]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||||
if (combined.length <= MAX_LOGS) return combined;
|
if (combined.length <= MAX_LOGS) return combined;
|
||||||
@@ -162,7 +125,7 @@ const filteredLogs = computed(() => {
|
|||||||
if (selectedLogLevel.value === 'all') {
|
if (selectedLogLevel.value === 'all') {
|
||||||
return mergedLogs.value;
|
return mergedLogs.value;
|
||||||
}
|
}
|
||||||
return mergedLogs.value.filter((log) => log.level === selectedLogLevel.value);
|
return mergedLogs.value.filter(log => log.level === selectedLogLevel.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
function scrollTableToBottom() {
|
function scrollTableToBottom() {
|
||||||
@@ -188,18 +151,12 @@ const sendCommand = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!apiName) {
|
if (!apiName) {
|
||||||
addLog({
|
addLog({ level: 'error', message: '指令格式错误:第一个 token 必须为 API 接口名' });
|
||||||
level: 'error',
|
|
||||||
message: '指令格式错误:第一个 token 必须为 API 接口名',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addLog({ level: 'info', message: `$ ${content}` });
|
addLog({ level: 'info', message: `$ ${content}` });
|
||||||
addLog({
|
addLog({ level: 'debug', message: `调用: ${apiName}${args.length ? ` args=${JSON.stringify(args)}` : ''}` });
|
||||||
level: 'debug',
|
|
||||||
message: `调用: ${apiName}${args.length ? ` args=${JSON.stringify(args)}` : ''}`,
|
|
||||||
});
|
|
||||||
scrollTableToBottom();
|
scrollTableToBottom();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -274,17 +231,13 @@ const handleScroll = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 监听过滤后的日志变化,自动滚动(如果启用)
|
// 监听过滤后的日志变化,自动滚动(如果启用)
|
||||||
watch(
|
watch(filteredLogs, () => {
|
||||||
filteredLogs,
|
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
|
||||||
() => {
|
setTimeout(() => {
|
||||||
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
|
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
||||||
setTimeout(() => {
|
}, 50);
|
||||||
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
}
|
||||||
}, 50);
|
}, { deep: true });
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
async function fetchRemoteLogs() {
|
async function fetchRemoteLogs() {
|
||||||
const projectName = props.projectName;
|
const projectName = props.projectName;
|
||||||
@@ -313,10 +266,7 @@ async function fetchRemoteLogs() {
|
|||||||
.slice(-MAX_LOGS);
|
.slice(-MAX_LOGS);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.response?.data?.message || err?.message || '读取失败';
|
const msg = err?.response?.data?.message || err?.message || '读取失败';
|
||||||
addLog({
|
addLog({ level: 'error', message: `读取 ${projectName}_项目控制台 失败: ${msg}` });
|
||||||
level: 'error',
|
|
||||||
message: `读取 ${projectName}_项目控制台 失败: ${msg}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,13 +284,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(() => props.projectName, async () => {
|
||||||
() => props.projectName,
|
await fetchRemoteLogs();
|
||||||
async () => {
|
scrollTableToBottom();
|
||||||
await fetchRemoteLogs();
|
});
|
||||||
scrollTableToBottom();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -712,6 +659,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
|
|
||||||
/* 桌面端:比默认再小 100px */
|
/* 桌面端:比默认再小 100px */
|
||||||
.log-table-wrapper {
|
.log-table-wrapper {
|
||||||
max-height: min(80vh, calc(100dvh - 200px));
|
max-height: min(80vh, calc(100dvh - 200px));
|
||||||
|
|||||||
@@ -2,78 +2,41 @@
|
|||||||
<div class="command-view">
|
<div class="command-view">
|
||||||
<div class="command-header">
|
<div class="command-header">
|
||||||
<h2>发送指令</h2>
|
<h2>发送指令</h2>
|
||||||
<div
|
<div v-if="projectName" class="project-info">
|
||||||
v-if="projectName"
|
|
||||||
class="project-info"
|
|
||||||
>
|
|
||||||
<span class="project-name">目标项目: {{ projectName }}</span>
|
<span class="project-name">目标项目: {{ projectName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="!projectName" class="no-project">
|
||||||
v-if="!projectName"
|
|
||||||
class="no-project"
|
|
||||||
>
|
|
||||||
<p>请先选择一个项目</p>
|
<p>请先选择一个项目</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-else class="command-content">
|
||||||
v-else
|
<form class="command-form" @submit.prevent="sendCommand">
|
||||||
class="command-content"
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
class="command-form"
|
|
||||||
@submit.prevent="sendCommand"
|
|
||||||
>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="command">指令内容</label>
|
<label for="command">指令内容</label>
|
||||||
<input
|
<input id="command" ref="commandInputRef" v-model="command" type="text" placeholder="输入:<接口名> <参数...>(按空格分割)"
|
||||||
id="command"
|
autocomplete="off" required>
|
||||||
ref="commandInputRef"
|
|
||||||
v-model="command"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入:<接口名> <参数...>(按空格分割)"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div class="command-hint">
|
<div class="command-hint">
|
||||||
示例: /api/status 或 /api/reload config
|
示例: /api/status 或 /api/reload config
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '发送中...' : '发送指令' }}
|
{{ loading ? '发送中...' : '发送指令' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="btn btn-secondary" :disabled="loading" @click="clearCommand">
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="clearCommand"
|
|
||||||
>
|
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div
|
<div v-if="response" class="response-section">
|
||||||
v-if="response"
|
|
||||||
class="response-section"
|
|
||||||
>
|
|
||||||
<h3>发送结果</h3>
|
<h3>发送结果</h3>
|
||||||
<div
|
<div class="response-content" :class="{ 'success': response.success, 'error': !response.success }">
|
||||||
class="response-content"
|
|
||||||
:class="{ success: response.success, error: !response.success }"
|
|
||||||
>
|
|
||||||
{{ response.message }}
|
{{ response.message }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="response.details" class="response-details">
|
||||||
v-if="response.details"
|
|
||||||
class="response-details"
|
|
||||||
>
|
|
||||||
<pre>{{ JSON.stringify(response.details, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(response.details, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,12 +113,9 @@ const clearCommand = () => {
|
|||||||
response.value = null;
|
response.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(() => props.projectName, () => {
|
||||||
() => props.projectName,
|
response.value = null;
|
||||||
() => {
|
});
|
||||||
response.value = null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (commandInputRef.value) {
|
if (commandInputRef.value) {
|
||||||
|
|||||||
@@ -2,73 +2,38 @@
|
|||||||
<div class="log-view">
|
<div class="log-view">
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<h2>日志记录</h2>
|
<h2>日志记录</h2>
|
||||||
<div
|
<div v-if="projectName" class="project-info">
|
||||||
v-if="projectName"
|
|
||||||
class="project-info"
|
|
||||||
>
|
|
||||||
<span class="project-name">{{ projectName }}</span>
|
<span class="project-name">{{ projectName }}</span>
|
||||||
<span
|
<span v-if="projectStatus" class="project-status" :class="`status-${projectStatus}`">
|
||||||
v-if="projectStatus"
|
|
||||||
class="project-status"
|
|
||||||
:class="`status-${projectStatus}`"
|
|
||||||
>
|
|
||||||
{{ projectStatus === '在线' ? '在线' : '离线' }}
|
{{ projectStatus === '在线' ? '在线' : '离线' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="!projectName" class="no-project">
|
||||||
v-if="!projectName"
|
|
||||||
class="no-project"
|
|
||||||
>
|
|
||||||
<p>请先选择一个项目</p>
|
<p>请先选择一个项目</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-else class="log-container">
|
||||||
v-else
|
<div v-if="loading" class="loading-state">
|
||||||
class="log-container"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="loading-state"
|
|
||||||
>
|
|
||||||
<p>加载日志中...</p>
|
<p>加载日志中...</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="error" class="error-state">
|
||||||
v-else-if="error"
|
|
||||||
class="error-state"
|
|
||||||
>
|
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="logs.length === 0" class="empty-state">
|
||||||
v-else-if="logs.length === 0"
|
|
||||||
class="empty-state"
|
|
||||||
>
|
|
||||||
<p>暂无日志记录</p>
|
<p>暂无日志记录</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="log-list">
|
||||||
v-else
|
<div v-for="(log, index) in logs" :key="log.id || index" class="log-item" :class="`level-${log.level}`">
|
||||||
class="log-list"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(log, index) in logs"
|
|
||||||
:key="log.id || index"
|
|
||||||
class="log-item"
|
|
||||||
:class="`level-${log.level}`"
|
|
||||||
>
|
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<span class="log-timestamp">
|
<span class="log-timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||||
{{ formatTimestamp(log.timestamp) }}
|
|
||||||
</span>
|
|
||||||
<span class="log-level">{{ log.level }}</span>
|
<span class="log-level">{{ log.level }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-message">
|
<div class="log-message">
|
||||||
{{ log.message }}
|
{{ log.message }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="log.metadata" class="log-metadata">
|
||||||
v-if="log.metadata"
|
|
||||||
class="log-metadata"
|
|
||||||
>
|
|
||||||
<pre>{{ JSON.stringify(log.metadata, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(log.metadata, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +70,7 @@ const fetchLogs = async () => {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(`http://localhost:3001/api/logs?projectName=${encodeURIComponent(props.projectName)}`);
|
||||||
`http://localhost:3001/api/logs?projectName=${encodeURIComponent(props.projectName)}`,
|
|
||||||
);
|
|
||||||
logs.value = response.data.logs || [];
|
logs.value = response.data.logs || [];
|
||||||
projectStatus.value = response.data.projectStatus;
|
projectStatus.value = response.data.projectStatus;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -133,12 +96,9 @@ const formatTimestamp = (timestamp) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(() => props.projectName, () => {
|
||||||
() => props.projectName,
|
fetchLogs();
|
||||||
() => {
|
});
|
||||||
fetchLogs();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.projectName) {
|
if (props.projectName) {
|
||||||
|
|||||||
Reference in New Issue
Block a user