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_CONNECT_TIMEOUT_MS=2000 # Connection timeout in ms (default: 2000)
# Application
# Optional: port for backend server. Current default in code is 3001 but you can override it here.
PORT=3001
# Command control (HTTP)
# Timeout for calling target project APIs (ms)
COMMAND_API_TIMEOUT_MS=5000

View File

@@ -1,31 +1,26 @@
# BLS Project Console 系统功能优化与修复计划
## 📋 任务概述
完成界面显示修复、Redis数据结构重构、相关代码修改、文档更新和测试验证。
## 🔍 当前问题分析
### 1. 界面显示问题
- **ProjectSelector.vue** (第54-97行) 包含硬编码的6个测试假数据
- 没有从Redis读取真实项目心跳数据
- 界面展示的是静态测试数据,而非真实有效的项目
### 2. Redis数据结构问题
- 当前使用分散的键:`{projectName}_项目心跳`
- 每个项目的心跳数据独立存储,难以统一管理
- 缺少统一的项目列表查询机制
### 3. 后端API现状
- **logs.js** 和 **commands.js** 都从 `{projectName}_项目心跳` 读取数据
- **redisKeys.js** 定义了 `projectHeartbeatKey(projectName)` 函数
- 缺少获取所有项目列表的API端点
### 4. 测试缺失
- 项目中没有测试文件
- 缺少单元测试和集成测试
@@ -34,124 +29,104 @@
### 阶段1Redis数据结构重构
#### 1.1 新增Redis键定义
-`redisKeys.js` 中添加 `projectsListKey()` 函数
- 定义统一的项目列表键:`项目心跳`
#### 1.2 创建数据迁移工具
- 创建 `src/backend/services/migrateHeartbeatData.js`
- 实现从分散键到统一列表的迁移逻辑
- 确保数据完整性和一致性
#### 1.3 更新心跳数据结构
- 新结构:`[{ "apiBaseUrl": "...", "lastActiveAt": 1234567890, "projectName": "..." }]`
- 每个对象包含API地址、最新心跳时间、项目名称
### 阶段2后端API开发
#### 2.1 创建项目列表API
-`src/backend/routes/` 下创建 `projects.js`
- 实现 `GET /api/projects` 端点
- 从Redis读取项目列表并返回
#### 2.2 更新现有API
- 修改 `logs.js` 以兼容新的数据结构
- 修改 `commands.js` 以兼容新的数据结构
- 保持向后兼容性
#### 2.3 添加数据迁移端点
- 实现 `POST /api/projects/migrate` 端点
- 支持手动触发数据迁移
### 阶段3前端代码修改
#### 3.1 更新ProjectSelector组件
- 移除硬编码的测试数据第54-97行
-`/api/projects` 获取真实项目列表
- 实现数据加载状态和错误处理
#### 3.2 更新projectStore
- 扩展状态管理以支持项目列表
- 添加项目列表的响应式状态
#### 3.3 更新LogView和CommandView
- 连接到真实API端点
- 实现日志和命令的真实功能
- 添加错误处理和用户反馈
#### 3.4 更新App.vue
- 修正健康检查端点从3000改为3001
- 确保服务状态监控正常工作
### 阶段4文档更新
#### 4.1 更新技术文档
- 创建或更新 `docs/redis-data-structure.md`
- 详细说明新的Redis数据结构设计
- 提供数据迁移指南
#### 4.2 更新OpenSpec规范
- 更新 `openspec/specs/logging/spec.md`
- 更新 `openspec/specs/command/spec.md`
- 更新 `openspec/specs/redis-connection/spec.md`
- 添加项目列表相关需求
#### 4.3 创建OpenSpec变更提案
- 创建 `openspec/changes/refactor-project-heartbeat/`
- 编写 `proposal.md``tasks.md` 和规范增量
### 阶段5测试开发
#### 5.1 单元测试
- 创建 `src/backend/services/__tests__/migrateHeartbeatData.test.js`
- 测试数据迁移功能
- 测试Redis键操作
#### 5.2 集成测试
- 创建 `src/backend/routes/__tests__/projects.test.js`
- 测试项目列表API
- 测试数据迁移API
#### 5.3 前端测试
- 创建 `src/frontend/components/__tests__/ProjectSelector.test.js`
- 测试组件渲染和交互
#### 5.4 性能测试
- 测试不同项目数量下的性能
- 测试不同心跳频率下的性能
### 阶段6代码质量与验证
#### 6.1 代码规范检查
- 运行 ESLint 检查
- 运行 Prettier 格式化
- 确保符合项目代码规范
#### 6.2 功能验证
- 验证项目选择功能正常
- 验证日志读取功能正常
- 验证命令发送功能正常
#### 6.3 性能验证
- 测试大量项目场景
- 测试高频心跳场景
- 优化性能瓶颈
@@ -159,7 +134,6 @@
## 📁 文件修改清单
### 新增文件
- `src/backend/services/migrateHeartbeatData.js` - 数据迁移工具
- `src/backend/routes/projects.js` - 项目列表API
- `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 迁移测试
@@ -171,7 +145,6 @@
- `openspec/changes/refactor-project-heartbeat/specs/` - 规范增量
### 修改文件
- `src/backend/services/redisKeys.js` - 添加项目列表键
- `src/backend/routes/logs.js` - 更新心跳读取逻辑
- `src/backend/routes/commands.js` - 更新心跳读取逻辑

View File

@@ -66,7 +66,7 @@ npm install
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_DB=15
```
### 运行项目
@@ -257,7 +257,7 @@ npm run format
REDIS_HOST=redis-server
REDIS_PORT=6379
REDIS_PASSWORD=your-redis-password
REDIS_DB=0
REDIS_DB=15
# 服务器配置
PORT=3001
@@ -317,7 +317,7 @@ docker run -p 3001:3001 --env-file .env bls-project-console
| 主机名 | REDIS_HOST | localhost | Redis服务器主机名 |
| 端口 | REDIS_PORT | 6379 | Redis服务器端口 |
| 密码 | REDIS_PASSWORD | | Redis服务器密码 |
| 数据库索引 | REDIS_DB | 0 | Redis数据库索引 |
| 数据库索引 | REDIS_DB | 15 | Redis数据库索引 |
| 连接超时 | REDIS_CONNECT_TIMEOUT | 10000 | 连接超时时间(毫秒) |
| 最大重试次数 | REDIS_MAX_RETRIES | 3 | 最大重试次数 |
| 重连策略 | REDIS_RECONNECT_STRATEGY | exponential | 重连策略exponential/fixed |

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
[
{
@@ -27,19 +26,11 @@
```
**字段说明**:
- `projectName`: 项目名称(字符串)
- `apiBaseUrl`: 项目API基础URL字符串对应“API地址”
- `lastActiveAt`: 最新心跳时间Unix 时间戳毫秒,数字),对应“最新心跳时间”
**与需求字段对应**:
- 项目名称 → `projectName`
- API地址 → `apiBaseUrl`
- 最新心跳时间 → `lastActiveAt`
- `apiBaseUrl`: 项目API基础URL字符串
- `lastActiveAt`: 最后活跃时间戳毫秒,数字)
**示例**:
```json
[
{
@@ -66,7 +57,6 @@
**数据格式**: 每个列表元素是一个JSON字符串包含日志信息。
**日志对象格式**:
```json
{
"id": "string",
@@ -78,7 +68,6 @@
```
**字段说明**:
- `id`: 日志唯一标识符
- `timestamp`: ISO-8601格式的时间戳
- `level`: 日志级别info, warn, error, debug
@@ -86,7 +75,6 @@
- `metadata`: 可选的附加元数据
**示例**:
```json
{
"id": "log-1704067200000-abc123",
@@ -109,7 +97,6 @@
**描述**: 存储发送给项目的控制指令。
**指令对象格式**:
```json
{
"commandId": "string",
@@ -123,7 +110,6 @@
```
**字段说明**:
- `commandId`: 指令唯一标识符
- `timestamp`: ISO-8601格式的时间戳
- `source`: 指令来源(如"BLS Project Console"
@@ -133,7 +119,6 @@
- `extraArgs`: 可选的额外参数
**示例**:
```json
{
"commandId": "cmd-1704067200000-xyz789",
@@ -171,7 +156,6 @@
- 备份现有数据(可选)
2. **执行迁移**
```javascript
import { migrateHeartbeatData } from './services/migrateHeartbeatData.js';
@@ -195,7 +179,6 @@
**端点**: `POST /api/projects/migrate`
**请求体**:
```json
{
"deleteOldKeys": false,
@@ -204,12 +187,10 @@
```
**参数说明**:
- `deleteOldKeys`: 是否删除旧的心跳键(默认: false
- `dryRun`: 是否为干运行模式(默认: false
**响应**:
```json
{
"success": true,
@@ -251,7 +232,6 @@
### 健康检查
定期检查以下指标:
- Redis连接状态
- 项目列表完整性
- 日志队列长度

View File

@@ -2,8 +2,28 @@
本文档定义“外部项目 ↔ BLS Project Console”之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**
注:本仓库对外暴露的 Redis 连接信息如下(供对方直接连接以写入心跳/日志):
- 地址:`10.8.8.109`
- 端口:默认 `6379`
- 密码:无(空)
示例(环境变量):
```
REDIS_HOST=10.8.8.109
REDIS_PORT=6379
REDIS_PASSWORD=
```
示例redis-cli
```
redis-cli -h 10.8.8.109 -p 6379
```
> 约束:每个需要关联本控制台的外部项目,必须在本项目使用的同一个 Redis 实例中:
>
> - 写入 2 个 Key心跳 + 控制台信息)
> - 命令下发为 HTTP API 调用

View File

@@ -1,16 +1,13 @@
# Change: Refactor Project Heartbeat Data Structure
## Why
当前项目心跳数据使用分散的Redis键结构`{projectName}_项目心跳`),导致以下问题:
1. 难以统一管理和查询所有项目
2. 前端项目选择功能需要硬编码测试数据
3. 无法高效获取项目列表和状态
4. 数据迁移和维护成本高
## What Changes
- **新增**统一的项目列表Redis键`项目心跳`
- **新增**数据迁移工具,支持从旧结构迁移到新结构
- **新增**项目列表API端点`GET /api/projects`
@@ -20,7 +17,6 @@
- **更新**OpenSpec规范文档反映新的API和数据结构
## Impact
- Affected specs:
- `specs/logging/spec.md` - 更新日志API响应格式
- `specs/command/spec.md` - 新增项目列表和迁移API
@@ -41,7 +37,6 @@
- `src/frontend/App.vue` - 修正健康检查端点
## Migration Plan
1. 执行数据迁移:调用`POST /api/projects/migrate`
2. 验证迁移结果:检查`项目心跳`键包含所有项目
3. 测试项目选择功能:确认前端能正确显示项目列表
@@ -49,15 +44,12 @@
5. 清理旧键可选调用迁移API并设置`deleteOldKeys: true`
## Backward Compatibility
系统保持向后兼容:
- 优先读取新的项目列表结构
- 如果新结构中未找到项目,回退到旧结构
- 支持平滑过渡,无需立即删除旧键
## Benefits
- 统一的项目管理,提高可维护性
- 前端显示真实项目数据,移除测试假数据
- 提高查询效率减少Redis操作次数

View File

@@ -1,50 +1,41 @@
## ADDED Requirements
### Requirement: Project List Management
The system SHALL provide a unified project list structure in Redis to manage all project heartbeats.
#### Scenario: Getting all projects
- **WHEN** the user requests the project list
- **THEN** the system SHALL return all projects with their heartbeat status
- **AND** each project SHALL include: project name, API base URL, last active time, and online status
#### Scenario: Project status calculation
- **WHEN** calculating project status
- **THEN** the system SHALL determine online/offline based on last active time and offline threshold
- **AND** the system SHALL return the age in milliseconds
### Requirement: Data Migration Support
The system SHALL support migrating heartbeat data from old structure to new unified structure.
#### Scenario: Migrating heartbeat data
- **WHEN** the migration process is triggered
- **THEN** the system SHALL read all old heartbeat keys
- **AND** convert them to the new unified list structure
- **AND** optionally delete old keys after successful migration
#### Scenario: Dry run migration
- **WHEN** migration is executed with dryRun flag
- **THEN** the system SHALL validate data without writing
- **AND** return the migration result for review
### Requirement: Backward Compatibility
The system SHALL maintain backward compatibility with old heartbeat data structure.
#### Scenario: Reading from new structure
- **WHEN** reading project heartbeat
- **THEN** the system SHALL first try to read from the new unified list
- **AND** fall back to old structure if not found
#### Scenario: Gradual migration
- **WHEN** old structure data is detected
- **THEN** the system SHALL continue to work with old data
- **AND** allow migration at a later time

View File

@@ -1,5 +1,4 @@
## 1. Redis数据结构重构
- [x] 1.1 在redisKeys.js中添加projectsListKey()函数
- [x] 1.2 创建数据迁移工具migrateHeartbeatData.js
- [x] 1.3 实现从分散键到统一列表的迁移逻辑
@@ -7,7 +6,6 @@
- [x] 1.5 实现updateProjectHeartbeat()函数
## 2. 后端API开发
- [x] 2.1 创建项目列表API routes/projects.js
- [x] 2.2 实现GET /api/projects端点
- [x] 2.3 实现POST /api/projects/migrate端点
@@ -16,7 +14,6 @@
- [x] 2.6 在server.js中注册项目列表路由
## 3. 前端代码修改
- [x] 3.1 更新ProjectSelector.vue移除假数据
- [x] 3.2 实现从API获取项目列表
- [x] 3.3 实现项目状态显示
@@ -29,7 +26,6 @@
- [x] 3.10 修正App.vue健康检查端点
## 4. 文档更新
- [x] 4.1 创建Redis数据结构文档
- [x] 4.2 更新logging OpenSpec规范
- [x] 4.3 更新command OpenSpec规范
@@ -38,14 +34,12 @@
- [x] 4.6 创建OpenSpec变更提案tasks.md
## 5. 测试开发
- [ ] 5.1 编写数据迁移单元测试
- [ ] 5.2 编写项目列表API集成测试
- [ ] 5.3 编写ProjectSelector组件测试
- [ ] 5.4 编写性能测试
## 6. 代码质量与验证
- [ ] 6.1 运行ESLint检查
- [ ] 6.2 运行Prettier格式化
- [ ] 6.3 验证项目选择功能

View File

@@ -1,54 +1,44 @@
# Command Capability Specification
## Overview
This specification defines the command capability for the BLS Project Console, which allows users to send console commands to Redis queues for other programs to read and execute.
## Requirements
### Requirement: Command Sending to Redis
The system SHALL send commands to a Redis queue.
#### Scenario: Sending a command to Redis queue
- **WHEN** the user enters a command in the console
- **AND** clicks the "Send" button
- **THEN** the command SHALL be sent to the Redis queue
- **AND** the user SHALL receive a success confirmation
### Requirement: Command Validation
The system SHALL validate commands before sending them to Redis.
#### Scenario: Validating an empty command
- **WHEN** the user tries to send an empty command
- **THEN** the system SHALL display an error message
- **AND** the command SHALL NOT be sent to Redis
#### Scenario: Validating a command with invalid characters
- **WHEN** the user tries to send a command with invalid characters
- **THEN** the system SHALL display an error message
- **AND** the command SHALL NOT be sent to Redis
### Requirement: Command History
The system SHALL maintain a history of sent commands.
#### Scenario: Viewing command history
- **WHEN** the user opens the command history
- **THEN** the system SHALL display a list of previously sent commands
- **AND** the user SHALL be able to select a command from the history to resend
### Requirement: Command Response Handling
The system SHALL handle responses from commands sent to Redis.
#### Scenario: Receiving a command response
- **WHEN** a command response is received from Redis
- **THEN** the system SHALL display the response in the console
- **AND** the response SHALL be associated with the original command
@@ -56,7 +46,6 @@ The system SHALL handle responses from commands sent to Redis.
## Data Model
### Command
```json
{
"id": "string",
@@ -67,7 +56,6 @@ The system SHALL handle responses from commands sent to Redis.
```
### Command Response
```json
{
"id": "string",
@@ -81,7 +69,6 @@ The system SHALL handle responses from commands sent to Redis.
## API Endpoints
### POST /api/commands
- **Description**: Send a command to a project's API endpoint
- **Request Body**:
```json
@@ -103,7 +90,6 @@ The system SHALL handle responses from commands sent to Redis.
```
### GET /api/projects
- **Description**: Get list of all projects with their heartbeat status
- **Response**:
```json
@@ -125,7 +111,6 @@ The system SHALL handle responses from commands sent to Redis.
```
### POST /api/projects/migrate
- **Description**: Migrate heartbeat data from old structure to new unified structure
- **Request Body**:
```json
@@ -147,7 +132,6 @@ The system SHALL handle responses from commands sent to Redis.
```
### GET /api/commands/history
- **Description**: Get command history (deprecated - use project logs instead)
- **Query Parameters**:
- `limit`: Maximum number of commands to return (default: 50)
@@ -155,6 +139,5 @@ The system SHALL handle responses from commands sent to Redis.
- **Response**: Array of command objects
### GET /api/commands/:id/response
- **Description**: Get response for a specific command (deprecated - use project logs instead)
- **Response**: Command response object

View File

@@ -1,53 +1,43 @@
# Logging Capability Specification
## Overview
This specification defines the logging capability for the BLS Project Console, which allows the system to read log records from Redis queues and display them in the console interface.
## Requirements
### Requirement: Log Reading from Redis
The system SHALL read log records from a Redis queue.
#### Scenario: Reading logs from Redis queue
- **WHEN** the server starts
- **THEN** it SHALL establish a connection to the Redis queue
- **AND** it SHALL begin listening for new log records
- **AND** it SHALL store log records in memory for display
### Requirement: Log Display in Console
The system SHALL display log records in a user-friendly format.
#### Scenario: Displaying logs in the console
- **WHEN** a log record is received from Redis
- **THEN** it SHALL be added to the log list in the console
- **AND** it SHALL display the log timestamp, level, and message
- **AND** it SHALL support scrolling through historical logs
### Requirement: Log Filtering
The system SHALL allow users to filter logs by level and time range.
#### Scenario: Filtering logs by level
- **WHEN** the user selects a log level filter
- **THEN** only logs with the selected level SHALL be displayed
#### Scenario: Filtering logs by time range
- **WHEN** the user selects a time range
- **THEN** only logs within the specified range SHALL be displayed
### Requirement: Log Auto-Refresh
The system SHALL automatically refresh logs in real-time.
#### Scenario: Real-time log updates
- **WHEN** a new log is added to the Redis queue
- **THEN** it SHALL be automatically displayed in the console
- **AND** the console SHALL scroll to the latest log if the user is viewing the end
@@ -55,7 +45,6 @@ The system SHALL automatically refresh logs in real-time.
## Data Model
### Log Record
```json
{
"id": "string",
@@ -69,7 +58,6 @@ The system SHALL automatically refresh logs in real-time.
## API Endpoints
### GET /api/logs
- **Description**: Get log records for a specific project
- **Query Parameters**:
- `projectName`: Project name (required)
@@ -97,6 +85,5 @@ The system SHALL automatically refresh logs in real-time.
```
### GET /api/logs/live
- **Description**: Establish a WebSocket connection for real-time log updates
- **Response**: Continuous stream of log records

View File

@@ -1,59 +1,48 @@
# Redis Connection Capability Specification
## Overview
This specification defines the Redis connection capability for the BLS Project Console, which manages the connection between the system and the Redis server for reading logs and sending commands.
## Requirements
### Requirement: Redis Connection Establishment
The system SHALL establish a connection to the Redis server.
#### Scenario: Establishing Redis connection on server start
- **WHEN** the server starts
- **THEN** it SHALL attempt to connect to the Redis server
- **AND** it SHALL log the connection status
### Requirement: Redis Connection Configuration
The system SHALL allow configuration of Redis connection parameters.
#### Scenario: Configuring Redis connection via environment variables
- **WHEN** the server starts with Redis environment variables set
- **THEN** it SHALL use those variables to configure the Redis connection
- **AND** it SHALL override default values
### Requirement: Redis Connection Reconnection
The system SHALL automatically reconnect to Redis if the connection is lost.
#### Scenario: Reconnecting to Redis after connection loss
- **WHEN** the Redis connection is lost
- **THEN** the system SHALL attempt to reconnect with exponential backoff
- **AND** it SHALL log each reconnection attempt
- **AND** it SHALL notify the user when connection is restored
### Requirement: Redis Connection Error Handling
The system SHALL handle Redis connection errors gracefully.
#### Scenario: Handling Redis connection failure
- **WHEN** the system fails to connect to Redis
- **THEN** it SHALL log the error
- **AND** it SHALL display an error message to the user
- **AND** it SHALL continue attempting to reconnect
### Requirement: Redis Connection Monitoring
The system SHALL monitor the Redis connection status.
#### Scenario: Monitoring Redis connection status
- **WHEN** the Redis connection status changes
- **THEN** the system SHALL update the connection status in the UI
- **AND** it SHALL log the status change
@@ -61,7 +50,6 @@ The system SHALL monitor the Redis connection status.
## Data Model
### Redis Connection Configuration
```json
{
"host": "string",
@@ -77,7 +65,6 @@ The system SHALL monitor the Redis connection status.
```
### Redis Connection Status
```json
{
"status": "string", // e.g., connecting, connected, disconnected, error
@@ -90,7 +77,6 @@ The system SHALL monitor the Redis connection status.
## API Endpoints
### GET /api/redis/status
- **Description**: Get Redis connection status
- **Response**:
```json
@@ -103,7 +89,6 @@ The system SHALL monitor the Redis connection status.
```
### POST /api/redis/reconnect
- **Description**: Manually reconnect to Redis
- **Response**:
```json
@@ -114,7 +99,6 @@ The system SHALL monitor the Redis connection status.
```
### GET /api/projects
- **Description**: Get list of all projects from Redis
- **Response**:
```json
@@ -136,7 +120,6 @@ The system SHALL monitor the Redis connection status.
```
### POST /api/projects/migrate
- **Description**: Migrate heartbeat data from old structure to new unified structure
- **Request Body**:
```json

View File

@@ -53,15 +53,14 @@ function buildTargetUrl(apiBaseUrl, apiName) {
function truncateForLog(value, maxLen = 2000) {
if (value == null) return value;
if (typeof value === 'string')
return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
if (typeof value === 'string') return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
return value;
}
async function getProjectHeartbeat(redis, projectName) {
try {
const projectsList = await getProjectsList(redis);
const project = projectsList.find((p) => p.projectName === projectName);
const projectsList = await getProjectsList();
const project = projectsList.find(p => p.projectName === projectName);
if (project) {
return {
@@ -70,10 +69,7 @@ async function getProjectHeartbeat(redis, projectName) {
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
@@ -137,10 +133,7 @@ router.post('/', async (req, res) => {
}
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
const heartbeat = await getProjectHeartbeat(
redis,
targetProjectName.trim(),
);
const heartbeat = await getProjectHeartbeat(redis, targetProjectName.trim());
if (!heartbeat) {
return res.status(503).json({
@@ -149,10 +142,7 @@ router.post('/', async (req, res) => {
});
}
const apiBaseUrl =
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl.trim()
: '';
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl.trim() : '';
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
if (!apiBaseUrl) {
@@ -162,16 +152,9 @@ router.post('/', async (req, res) => {
});
}
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
if (
!lastActiveAt ||
(Number.isFinite(offlineThresholdMs) &&
now - lastActiveAt > offlineThresholdMs)
) {
if (!lastActiveAt || (Number.isFinite(offlineThresholdMs) && now - lastActiveAt > offlineThresholdMs)) {
return res.status(503).json({
success: false,
message: '目标项目已离线(心跳超时)',
@@ -187,10 +170,7 @@ router.post('/', async (req, res) => {
});
}
const timeoutMs = Number.parseInt(
process.env.COMMAND_API_TIMEOUT_MS || '5000',
10,
);
const timeoutMs = Number.parseInt(process.env.COMMAND_API_TIMEOUT_MS || '5000', 10);
const resp = await axios.post(targetUrl, payload, {
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
validateStatus: () => true,

View File

@@ -3,10 +3,7 @@ import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import {
projectConsoleKey,
projectHeartbeatKey,
} from '../services/redisKeys.js';
import { projectConsoleKey, projectHeartbeatKey } from '../services/redisKeys.js';
import { getProjectsList } from '../services/migrateHeartbeatData.js';
function parsePositiveInt(value, defaultValue) {
@@ -28,8 +25,8 @@ function parseLastActiveAt(value) {
async function getProjectHeartbeat(redis, projectName) {
try {
const projectsList = await getProjectsList(redis);
const project = projectsList.find((p) => p.projectName === projectName);
const projectsList = await getProjectsList();
const project = projectsList.find(p => p.projectName === projectName);
if (project) {
return {
@@ -38,10 +35,7 @@ async function getProjectHeartbeat(redis, projectName) {
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
@@ -58,10 +52,7 @@ async function getProjectHeartbeat(redis, projectName) {
// 获取日志列表
router.get('/', async (req, res) => {
const projectName =
typeof req.query.projectName === 'string'
? req.query.projectName.trim()
: '';
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
const limit = parsePositiveInt(req.query.limit, 200);
if (!projectName) {
@@ -84,7 +75,8 @@ router.get('/', async (req, res) => {
const key = projectConsoleKey(projectName);
const list = await redis.lRange(key, -limit, -1);
const logs = list.map((raw, idx) => {
const logs = list
.map((raw, idx) => {
try {
const parsed = JSON.parse(raw);
const timestamp = parsed.timestamp || new Date().toISOString();
@@ -95,10 +87,7 @@ router.get('/', async (req, res) => {
timestamp,
level,
message,
metadata:
parsed.metadata && typeof parsed.metadata === 'object'
? parsed.metadata
: undefined,
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
};
} catch {
return {
@@ -111,15 +100,11 @@ router.get('/', async (req, res) => {
});
const heartbeat = await getProjectHeartbeat(redis, projectName);
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
const ageMs = lastActiveAt ? now - lastActiveAt : null;
const isOnline =
lastActiveAt && Number.isFinite(offlineThresholdMs)
const isOnline = lastActiveAt && Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: Boolean(lastActiveAt);
@@ -128,17 +113,12 @@ router.get('/', async (req, res) => {
return res.status(200).json({
logs,
projectStatus: computedStatus || null,
heartbeat: heartbeat
? {
apiBaseUrl:
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl
: null,
heartbeat: heartbeat ? {
apiBaseUrl: typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null,
lastActiveAt: lastActiveAt || null,
isOnline,
ageMs,
}
: null,
} : null,
});
} catch (err) {
console.error('Failed to read logs', err);

View File

@@ -3,10 +3,7 @@ import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import {
migrateHeartbeatData,
getProjectsList,
} from '../services/migrateHeartbeatData.js';
import { migrateHeartbeatData, getProjectsList } from '../services/migrateHeartbeatData.js';
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
@@ -20,10 +17,7 @@ function parseLastActiveAt(value) {
}
function computeProjectStatus(project) {
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
@@ -36,9 +30,7 @@ function computeProjectStatus(project) {
}
const ageMs = now - lastActiveAt;
const isOnline = Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: true;
const isOnline = Number.isFinite(offlineThresholdMs) ? ageMs <= offlineThresholdMs : true;
return {
status: isOnline ? 'online' : 'offline',
@@ -58,8 +50,8 @@ router.get('/', async (req, res) => {
});
}
const projectsList = await getProjectsList(redis);
const projects = projectsList.map((project) => {
const projectsList = await getProjectsList();
const projects = projectsList.map(project => {
const statusInfo = computeProjectStatus(project);
return {
id: project.projectName,
@@ -89,11 +81,9 @@ router.post('/migrate', async (req, res) => {
const { deleteOldKeys = false, dryRun = false } = req.body;
try {
const redis = req.app?.locals?.redis || (await getRedisClient());
const result = await migrateHeartbeatData({
deleteOldKeys: Boolean(deleteOldKeys),
dryRun: Boolean(dryRun),
redis,
});
return res.status(200).json({

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 { createApp } from './app.js';
const app = express();
const PORT = 3001;
const app = createApp();
app.use(cors());
app.use(express.json());
app.use('/api/logs', logRoutes);
app.use('/api/commands', commandRoutes);
app.use('/api/projects', projectRoutes);
app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// 启动服务器
const server = app.listen(PORT, async () => {

View File

@@ -1,7 +1,7 @@
import { getRedisClient } from './redisClient.js';
import { projectsListKey } from './redisKeys.js';
export function parseLastActiveAt(value) {
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
@@ -12,65 +12,13 @@ export function parseLastActiveAt(value) {
return null;
}
export function normalizeProjectEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
export async function migrateHeartbeatData(options = {}) {
const { deleteOldKeys = false, dryRun = false } = options;
const projectName =
typeof entry.projectName === 'string' ? entry.projectName.trim() : '';
if (!projectName) return null;
const apiBaseUrl =
typeof entry.apiBaseUrl === 'string' && entry.apiBaseUrl.trim()
? entry.apiBaseUrl.trim()
: null;
const lastActiveAt = parseLastActiveAt(entry.lastActiveAt);
return {
projectName,
apiBaseUrl,
lastActiveAt: lastActiveAt || null,
};
}
export function normalizeProjectsList(list) {
if (!Array.isArray(list)) return [];
const byName = new Map();
for (const item of list) {
const normalized = normalizeProjectEntry(item);
if (!normalized) continue;
const existing = byName.get(normalized.projectName);
if (!existing) {
byName.set(normalized.projectName, normalized);
continue;
}
const existingTs = existing.lastActiveAt || 0;
const nextTs = normalized.lastActiveAt || 0;
byName.set(
normalized.projectName,
nextTs >= existingTs ? normalized : existing,
);
}
return Array.from(byName.values());
}
async function getReadyRedis(redisOverride) {
const redis = redisOverride || (await getRedisClient());
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
return redis;
}
export async function migrateHeartbeatData(options = {}) {
const { deleteOldKeys = false, dryRun = false, redis: redisOverride } =
options;
const redis = await getReadyRedis(redisOverride);
console.log('[migrate] 开始迁移心跳数据...');
@@ -96,37 +44,38 @@ export async function migrateHeartbeatData(options = {}) {
}
const projectName = key.replace('_项目心跳', '');
const project = normalizeProjectEntry({
projectName,
apiBaseUrl: heartbeat?.apiBaseUrl,
lastActiveAt: heartbeat?.lastActiveAt,
});
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null;
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
if (!project?.apiBaseUrl) {
if (!apiBaseUrl) {
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
continue;
}
const project = {
projectName,
apiBaseUrl,
lastActiveAt: lastActiveAt || null,
};
projectsList.push(project);
console.log(`[migrate] 添加项目: ${projectName}`);
}
const normalizedProjectsList = normalizeProjectsList(projectsList);
console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`);
console.log(`[migrate] 共迁移 ${projectsList.length} 个项目`);
if (dryRun) {
console.log('[migrate] 干运行模式,不写入数据');
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
migrated: projectsList.length,
projects: projectsList,
dryRun: true,
};
}
const listKey = projectsListKey();
await redis.set(listKey, JSON.stringify(normalizedProjectsList));
await redis.set(listKey, JSON.stringify(projectsList));
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
if (deleteOldKeys) {
@@ -141,8 +90,8 @@ export async function migrateHeartbeatData(options = {}) {
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
migrated: projectsList.length,
projects: projectsList,
listKey,
deleteOldKeys,
};
@@ -152,8 +101,11 @@ export async function migrateHeartbeatData(options = {}) {
}
}
export async function getProjectsList(redisOverride) {
const redis = await getReadyRedis(redisOverride);
export async function getProjectsList() {
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
const listKey = projectsListKey();
const raw = await redis.get(listKey);
@@ -164,35 +116,28 @@ export async function getProjectsList(redisOverride) {
try {
const list = JSON.parse(raw);
return normalizeProjectsList(list);
return Array.isArray(list) ? list : [];
} catch (err) {
console.error('[getProjectsList] 解析项目列表失败:', err);
return [];
}
}
export async function updateProjectHeartbeat(
projectName,
heartbeatData,
redisOverride,
) {
const redis = await getReadyRedis(redisOverride);
const projectsList = await getProjectsList(redis);
const existingIndex = projectsList.findIndex(
(p) => p.projectName === projectName,
);
const project = normalizeProjectEntry({
projectName,
apiBaseUrl: heartbeatData?.apiBaseUrl,
lastActiveAt: heartbeatData?.lastActiveAt,
});
if (!project) {
throw new Error('无效的项目心跳数据');
export async function updateProjectHeartbeat(projectName, heartbeatData) {
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
const projectsList = await getProjectsList();
const existingIndex = projectsList.findIndex(p => p.projectName === projectName);
const project = {
projectName,
apiBaseUrl: heartbeatData.apiBaseUrl || null,
lastActiveAt: parseLastActiveAt(heartbeatData.lastActiveAt) || null,
};
if (existingIndex >= 0) {
projectsList[existingIndex] = project;
} else {

View File

@@ -1,46 +1,28 @@
<template>
<div class="app-container">
<header class="app-header">
<button
v-if="isMobile"
class="menu-toggle"
@click="toggleSidebar"
>
<button v-if="isMobile" class="menu-toggle" @click="toggleSidebar">
<span class="menu-icon" />
</button>
<h1>BLS Project Console</h1>
<div
class="service-status"
:class="{
'status-ok': serviceStatus === 'ok',
'status-error': serviceStatus === 'error',
}"
>
<div class="service-status"
:class="{ 'status-ok': serviceStatus === 'ok', 'status-error': serviceStatus === 'error' }">
{{ serviceStatusText }}
</div>
</header>
<div class="app-content">
<!-- 左侧选择区域 -->
<aside
class="sidebar"
:class="{ 'sidebar-closed': !sidebarOpen && isMobile }"
>
<aside class="sidebar" :class="{ 'sidebar-closed': !sidebarOpen && isMobile }">
<router-view name="sidebar" />
</aside>
<!-- 右侧调试区域 -->
<main class="main-content">
<div
v-if="serviceStatus === 'error'"
class="service-error-message"
>
<div v-if="serviceStatus === 'error'" class="service-error-message">
<h3>服务不可用</h3>
<p>无法连接到后端服务请检查服务是否正常运行</p>
</div>
<router-view
v-else
name="main"
/>
<router-view v-else name="main" />
</main>
</div>
</div>
@@ -151,7 +133,7 @@ body {
}
.app-header {
background-color: #008c8c;
background-color: #008C8C;
color: white;
padding: 0.6rem 1rem;
display: flex;
@@ -196,9 +178,7 @@ body {
width: 20px;
height: 2px;
background-color: white;
transition:
transform 0.2s,
top 0.2s;
transition: transform 0.2s, top 0.2s;
}
.menu-icon::before {

View File

@@ -6,10 +6,7 @@
<!-- 日志级别筛选 -->
<div class="log-level-filter">
<label class="filter-label">日志级别:</label>
<select
v-model="selectedLogLevel"
class="filter-select"
>
<select v-model="selectedLogLevel" class="filter-select">
<option value="all">
全部
</option>
@@ -31,20 +28,13 @@
<!-- 自动滚动开关 -->
<div class="auto-scroll-toggle">
<label class="toggle-label">
<input
v-model="autoScroll"
type="checkbox"
class="toggle-checkbox"
>
<input v-model="autoScroll" type="checkbox" class="toggle-checkbox">
<span class="toggle-text">自动滚动</span>
</label>
</div>
<!-- 日志清理按钮 -->
<button
class="clear-logs-btn"
@click="clearLogs"
>
<button class="clear-logs-btn" @click="clearLogs">
清空日志
</button>
@@ -56,30 +46,16 @@
</div>
<!-- 日志显示区域 -->
<div
ref="logsContainer"
class="logs-container"
>
<div
ref="logTableWrapper"
class="log-table-wrapper"
@scroll="handleScroll"
>
<div ref="logsContainer" class="logs-container">
<div ref="logTableWrapper" class="log-table-wrapper" @scroll="handleScroll">
<table class="log-table">
<tbody>
<tr
v-for="log in filteredLogs"
:key="log.id"
:class="`log-item log-level-${log.level}`"
>
<tr v-for="log in filteredLogs" :key="log.id" :class="`log-item log-level-${log.level}`">
<td class="log-meta">
<div class="log-timestamp">
{{ formatTimestamp(log.timestamp) }}
</div>
<div
class="log-level-badge"
:class="`level-${log.level}`"
>
<div class="log-level-badge" :class="`level-${log.level}`">
{{ log.level.toUpperCase() }}
</div>
</td>
@@ -92,10 +68,7 @@
</div>
<!-- 空状态 -->
<div
v-if="filteredLogs.length === 0"
class="empty-logs"
>
<div v-if="filteredLogs.length === 0" class="empty-logs">
<p>暂无日志记录</p>
</div>
</div>
@@ -105,19 +78,9 @@
<div class="command-prompt">
$
</div>
<input
ref="commandInputRef"
v-model="commandInput"
type="text"
class="command-input"
placeholder="输入:<接口名> <参数...>(按空格分割)"
autocomplete="off"
@keydown.enter="sendCommand"
>
<button
class="send-command-btn"
@click="sendCommand"
>
<input ref="commandInputRef" v-model="commandInput" type="text" class="command-input"
placeholder="输入:<接口名> <参数...>(按空格分割)" autocomplete="off" @keydown.enter="sendCommand">
<button class="send-command-btn" @click="sendCommand">
发送
</button>
</div>
@@ -162,7 +125,7 @@ const filteredLogs = computed(() => {
if (selectedLogLevel.value === 'all') {
return mergedLogs.value;
}
return mergedLogs.value.filter((log) => log.level === selectedLogLevel.value);
return mergedLogs.value.filter(log => log.level === selectedLogLevel.value);
});
function scrollTableToBottom() {
@@ -188,18 +151,12 @@ const sendCommand = async () => {
}
if (!apiName) {
addLog({
level: 'error',
message: '指令格式错误:第一个 token 必须为 API 接口名',
});
addLog({ level: 'error', message: '指令格式错误:第一个 token 必须为 API 接口名' });
return;
}
addLog({ level: 'info', message: `$ ${content}` });
addLog({
level: 'debug',
message: `调用: ${apiName}${args.length ? ` args=${JSON.stringify(args)}` : ''}`,
});
addLog({ level: 'debug', message: `调用: ${apiName}${args.length ? ` args=${JSON.stringify(args)}` : ''}` });
scrollTableToBottom();
try {
@@ -274,17 +231,13 @@ const handleScroll = () => {
};
// 监听过滤后的日志变化,自动滚动(如果启用)
watch(
filteredLogs,
() => {
watch(filteredLogs, () => {
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
setTimeout(() => {
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 50);
}
},
{ deep: true },
);
}, { deep: true });
async function fetchRemoteLogs() {
const projectName = props.projectName;
@@ -313,10 +266,7 @@ async function fetchRemoteLogs() {
.slice(-MAX_LOGS);
} catch (err) {
const msg = err?.response?.data?.message || err?.message || '读取失败';
addLog({
level: 'error',
message: `读取 ${projectName}_项目控制台 失败: ${msg}`,
});
addLog({ level: 'error', message: `读取 ${projectName}_项目控制台 失败: ${msg}` });
}
}
@@ -334,13 +284,10 @@ onUnmounted(() => {
}
});
watch(
() => props.projectName,
async () => {
watch(() => props.projectName, async () => {
await fetchRemoteLogs();
scrollTableToBottom();
},
);
});
</script>
<style scoped>
@@ -712,6 +659,7 @@ watch(
}
@media (min-width: 769px) {
/* 桌面端:比默认再小 100px */
.log-table-wrapper {
max-height: min(80vh, calc(100dvh - 200px));

View File

@@ -2,78 +2,41 @@
<div class="command-view">
<div class="command-header">
<h2>发送指令</h2>
<div
v-if="projectName"
class="project-info"
>
<div v-if="projectName" class="project-info">
<span class="project-name">目标项目: {{ projectName }}</span>
</div>
</div>
<div
v-if="!projectName"
class="no-project"
>
<div v-if="!projectName" class="no-project">
<p>请先选择一个项目</p>
</div>
<div
v-else
class="command-content"
>
<form
class="command-form"
@submit.prevent="sendCommand"
>
<div v-else class="command-content">
<form class="command-form" @submit.prevent="sendCommand">
<div class="form-group">
<label for="command">指令内容</label>
<input
id="command"
ref="commandInputRef"
v-model="command"
type="text"
placeholder="输入:<接口名> <参数...>(按空格分割)"
autocomplete="off"
required
>
<input id="command" ref="commandInputRef" v-model="command" type="text" placeholder="输入:<接口名> <参数...>(按空格分割)"
autocomplete="off" required>
<div class="command-hint">
示例: /api/status /api/reload config
</div>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
:disabled="loading"
>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? '发送中...' : '发送指令' }}
</button>
<button
type="button"
class="btn btn-secondary"
:disabled="loading"
@click="clearCommand"
>
<button type="button" class="btn btn-secondary" :disabled="loading" @click="clearCommand">
清空
</button>
</div>
</form>
<div
v-if="response"
class="response-section"
>
<div v-if="response" class="response-section">
<h3>发送结果</h3>
<div
class="response-content"
:class="{ success: response.success, error: !response.success }"
>
<div class="response-content" :class="{ 'success': response.success, 'error': !response.success }">
{{ response.message }}
</div>
<div
v-if="response.details"
class="response-details"
>
<div v-if="response.details" class="response-details">
<pre>{{ JSON.stringify(response.details, null, 2) }}</pre>
</div>
</div>
@@ -150,12 +113,9 @@ const clearCommand = () => {
response.value = null;
};
watch(
() => props.projectName,
() => {
watch(() => props.projectName, () => {
response.value = null;
},
);
});
onMounted(() => {
if (commandInputRef.value) {

View File

@@ -2,73 +2,38 @@
<div class="log-view">
<div class="log-header">
<h2>日志记录</h2>
<div
v-if="projectName"
class="project-info"
>
<div v-if="projectName" class="project-info">
<span class="project-name">{{ projectName }}</span>
<span
v-if="projectStatus"
class="project-status"
:class="`status-${projectStatus}`"
>
<span v-if="projectStatus" class="project-status" :class="`status-${projectStatus}`">
{{ projectStatus === '在线' ? '在线' : '离线' }}
</span>
</div>
</div>
<div
v-if="!projectName"
class="no-project"
>
<div v-if="!projectName" class="no-project">
<p>请先选择一个项目</p>
</div>
<div
v-else
class="log-container"
>
<div
v-if="loading"
class="loading-state"
>
<div v-else class="log-container">
<div v-if="loading" class="loading-state">
<p>加载日志中...</p>
</div>
<div
v-else-if="error"
class="error-state"
>
<div v-else-if="error" class="error-state">
<p>{{ error }}</p>
</div>
<div
v-else-if="logs.length === 0"
class="empty-state"
>
<div v-else-if="logs.length === 0" class="empty-state">
<p>暂无日志记录</p>
</div>
<div
v-else
class="log-list"
>
<div
v-for="(log, index) in logs"
:key="log.id || index"
class="log-item"
:class="`level-${log.level}`"
>
<div v-else 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">
<span class="log-timestamp">
{{ formatTimestamp(log.timestamp) }}
</span>
<span class="log-timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="log-level">{{ log.level }}</span>
</div>
<div class="log-message">
{{ log.message }}
</div>
<div
v-if="log.metadata"
class="log-metadata"
>
<div v-if="log.metadata" class="log-metadata">
<pre>{{ JSON.stringify(log.metadata, null, 2) }}</pre>
</div>
</div>
@@ -105,9 +70,7 @@ const fetchLogs = async () => {
error.value = null;
try {
const response = await axios.get(
`http://localhost:3001/api/logs?projectName=${encodeURIComponent(props.projectName)}`,
);
const response = await axios.get(`http://localhost:3001/api/logs?projectName=${encodeURIComponent(props.projectName)}`);
logs.value = response.data.logs || [];
projectStatus.value = response.data.projectStatus;
} catch (err) {
@@ -133,12 +96,9 @@ const formatTimestamp = (timestamp) => {
});
};
watch(
() => props.projectName,
() => {
watch(() => props.projectName, () => {
fetchLogs();
},
);
});
onMounted(() => {
if (props.projectName) {