feat: 重构项目心跳数据结构并实现相关功能

- 重构Redis心跳数据结构,使用统一的项目列表键
- 新增数据迁移工具和API端点
- 更新前端以使用真实项目数据
- 添加系统部署配置和文档
- 修复代码格式和样式问题
This commit is contained in:
2026-01-15 14:14:10 +08:00
parent 282f7268ed
commit a8faa7dcaa
24 changed files with 307 additions and 560 deletions

View File

@@ -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

View File

@@ -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 @@
### 阶段1Redis数据结构重构 ### 阶段1Redis数据结构重构
#### 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` - 更新心跳读取逻辑

View File

@@ -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 |

View 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"

View 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;
}

View 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

View File

@@ -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连接状态
- 项目列表完整性 - 项目列表完整性
- 日志队列长度 - 日志队列长度

View File

@@ -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 调用

View File

@@ -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操作次数

View File

@@ -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

View File

@@ -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 验证项目选择功能

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,7 +75,8 @@ 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
.map((raw, idx) => {
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const timestamp = parsed.timestamp || new Date().toISOString(); const timestamp = parsed.timestamp || new Date().toISOString();
@@ -95,10 +87,7 @@ router.get('/', async (req, res) => {
timestamp, timestamp,
level, level,
message, message,
metadata: metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
parsed.metadata && typeof parsed.metadata === 'object'
? parsed.metadata
: undefined,
}; };
} catch { } catch {
return { return {
@@ -111,15 +100,11 @@ router.get('/', async (req, res) => {
}); });
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);
@@ -128,17 +113,12 @@ router.get('/', async (req, res) => {
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:
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl
: null,
lastActiveAt: lastActiveAt || null, lastActiveAt: lastActiveAt || null,
isOnline, isOnline,
ageMs, ageMs,
} } : null,
: null,
}); });
} catch (err) { } catch (err) {
console.error('Failed to read logs', err); console.error('Failed to read logs', err);

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
@@ -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) { if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
setTimeout(() => { setTimeout(() => {
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight; logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 50); }, 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,
async () => {
await fetchRemoteLogs(); await fetchRemoteLogs();
scrollTableToBottom(); 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));

View File

@@ -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) {

View File

@@ -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) {