feat: 重构项目心跳数据结构并实现相关功能
- 重构Redis心跳数据结构,使用统一的项目列表键 - 新增数据迁移工具和API端点 - 更新前端以使用真实项目数据 - 添加系统部署配置和文档 - 修复代码格式和样式问题
This commit is contained in:
@@ -8,10 +8,6 @@ REDIS_PASSWORD= # Redis password (leave empty if not set)
|
||||
REDIS_DB=0 # Redis database number (default: 0)
|
||||
REDIS_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
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
# BLS Project Console 系统功能优化与修复计划
|
||||
|
||||
## 📋 任务概述
|
||||
|
||||
完成界面显示修复、Redis数据结构重构、相关代码修改、文档更新和测试验证。
|
||||
|
||||
## 🔍 当前问题分析
|
||||
|
||||
### 1. 界面显示问题
|
||||
|
||||
- **ProjectSelector.vue** (第54-97行) 包含硬编码的6个测试假数据
|
||||
- 没有从Redis读取真实项目心跳数据
|
||||
- 界面展示的是静态测试数据,而非真实有效的项目
|
||||
|
||||
### 2. Redis数据结构问题
|
||||
|
||||
- 当前使用分散的键:`{projectName}_项目心跳`
|
||||
- 每个项目的心跳数据独立存储,难以统一管理
|
||||
- 缺少统一的项目列表查询机制
|
||||
|
||||
### 3. 后端API现状
|
||||
|
||||
- **logs.js** 和 **commands.js** 都从 `{projectName}_项目心跳` 读取数据
|
||||
- **redisKeys.js** 定义了 `projectHeartbeatKey(projectName)` 函数
|
||||
- 缺少获取所有项目列表的API端点
|
||||
|
||||
### 4. 测试缺失
|
||||
|
||||
- 项目中没有测试文件
|
||||
- 缺少单元测试和集成测试
|
||||
|
||||
@@ -34,124 +29,104 @@
|
||||
### 阶段1:Redis数据结构重构
|
||||
|
||||
#### 1.1 新增Redis键定义
|
||||
|
||||
- 在 `redisKeys.js` 中添加 `projectsListKey()` 函数
|
||||
- 定义统一的项目列表键:`项目心跳`
|
||||
|
||||
#### 1.2 创建数据迁移工具
|
||||
|
||||
- 创建 `src/backend/services/migrateHeartbeatData.js`
|
||||
- 实现从分散键到统一列表的迁移逻辑
|
||||
- 确保数据完整性和一致性
|
||||
|
||||
#### 1.3 更新心跳数据结构
|
||||
|
||||
- 新结构:`[{ "apiBaseUrl": "...", "lastActiveAt": 1234567890, "projectName": "..." }]`
|
||||
- 每个对象包含:API地址、最新心跳时间、项目名称
|
||||
|
||||
### 阶段2:后端API开发
|
||||
|
||||
#### 2.1 创建项目列表API
|
||||
|
||||
- 在 `src/backend/routes/` 下创建 `projects.js`
|
||||
- 实现 `GET /api/projects` 端点
|
||||
- 从Redis读取项目列表并返回
|
||||
|
||||
#### 2.2 更新现有API
|
||||
|
||||
- 修改 `logs.js` 以兼容新的数据结构
|
||||
- 修改 `commands.js` 以兼容新的数据结构
|
||||
- 保持向后兼容性
|
||||
|
||||
#### 2.3 添加数据迁移端点
|
||||
|
||||
- 实现 `POST /api/projects/migrate` 端点
|
||||
- 支持手动触发数据迁移
|
||||
|
||||
### 阶段3:前端代码修改
|
||||
|
||||
#### 3.1 更新ProjectSelector组件
|
||||
|
||||
- 移除硬编码的测试数据(第54-97行)
|
||||
- 从 `/api/projects` 获取真实项目列表
|
||||
- 实现数据加载状态和错误处理
|
||||
|
||||
#### 3.2 更新projectStore
|
||||
|
||||
- 扩展状态管理以支持项目列表
|
||||
- 添加项目列表的响应式状态
|
||||
|
||||
#### 3.3 更新LogView和CommandView
|
||||
|
||||
- 连接到真实API端点
|
||||
- 实现日志和命令的真实功能
|
||||
- 添加错误处理和用户反馈
|
||||
|
||||
#### 3.4 更新App.vue
|
||||
|
||||
- 修正健康检查端点(从3000改为3001)
|
||||
- 确保服务状态监控正常工作
|
||||
|
||||
### 阶段4:文档更新
|
||||
|
||||
#### 4.1 更新技术文档
|
||||
|
||||
- 创建或更新 `docs/redis-data-structure.md`
|
||||
- 详细说明新的Redis数据结构设计
|
||||
- 提供数据迁移指南
|
||||
|
||||
#### 4.2 更新OpenSpec规范
|
||||
|
||||
- 更新 `openspec/specs/logging/spec.md`
|
||||
- 更新 `openspec/specs/command/spec.md`
|
||||
- 更新 `openspec/specs/redis-connection/spec.md`
|
||||
- 添加项目列表相关需求
|
||||
|
||||
#### 4.3 创建OpenSpec变更提案
|
||||
|
||||
- 创建 `openspec/changes/refactor-project-heartbeat/`
|
||||
- 编写 `proposal.md`、`tasks.md` 和规范增量
|
||||
|
||||
### 阶段5:测试开发
|
||||
|
||||
#### 5.1 单元测试
|
||||
|
||||
- 创建 `src/backend/services/__tests__/migrateHeartbeatData.test.js`
|
||||
- 测试数据迁移功能
|
||||
- 测试Redis键操作
|
||||
|
||||
#### 5.2 集成测试
|
||||
|
||||
- 创建 `src/backend/routes/__tests__/projects.test.js`
|
||||
- 测试项目列表API
|
||||
- 测试数据迁移API
|
||||
|
||||
#### 5.3 前端测试
|
||||
|
||||
- 创建 `src/frontend/components/__tests__/ProjectSelector.test.js`
|
||||
- 测试组件渲染和交互
|
||||
|
||||
#### 5.4 性能测试
|
||||
|
||||
- 测试不同项目数量下的性能
|
||||
- 测试不同心跳频率下的性能
|
||||
|
||||
### 阶段6:代码质量与验证
|
||||
|
||||
#### 6.1 代码规范检查
|
||||
|
||||
- 运行 ESLint 检查
|
||||
- 运行 Prettier 格式化
|
||||
- 确保符合项目代码规范
|
||||
|
||||
#### 6.2 功能验证
|
||||
|
||||
- 验证项目选择功能正常
|
||||
- 验证日志读取功能正常
|
||||
- 验证命令发送功能正常
|
||||
|
||||
#### 6.3 性能验证
|
||||
|
||||
- 测试大量项目场景
|
||||
- 测试高频心跳场景
|
||||
- 优化性能瓶颈
|
||||
@@ -159,7 +134,6 @@
|
||||
## 📁 文件修改清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `src/backend/services/migrateHeartbeatData.js` - 数据迁移工具
|
||||
- `src/backend/routes/projects.js` - 项目列表API
|
||||
- `src/backend/services/__tests__/migrateHeartbeatData.test.js` - 迁移测试
|
||||
@@ -171,7 +145,6 @@
|
||||
- `openspec/changes/refactor-project-heartbeat/specs/` - 规范增量
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `src/backend/services/redisKeys.js` - 添加项目列表键
|
||||
- `src/backend/routes/logs.js` - 更新心跳读取逻辑
|
||||
- `src/backend/routes/commands.js` - 更新心跳读取逻辑
|
||||
|
||||
@@ -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) |
|
||||
|
||||
21
deploy/docker/docker-compose.backend.yml
Normal file
21
deploy/docker/docker-compose.backend.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: node:22-alpine
|
||||
container_name: bls-project-console-app
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- /vol1/1000/Docker/nginx/project/bls/bls_project_console:/app
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
REDIS_HOST: 10.8.8.109
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ""
|
||||
REDIS_DB: 0
|
||||
HEARTBEAT_OFFLINE_THRESHOLD_MS: 10000
|
||||
COMMAND_API_TIMEOUT_MS: 5000
|
||||
command: ["node", "src/backend/server.js"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
47
deploy/nginx/conf.d/bls_project_console.conf
Normal file
47
deploy/nginx/conf.d/bls_project_console.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
# BLS Project Console - Nginx site (for nginx-in-docker)
|
||||
#
|
||||
# Place this file on SERVER:
|
||||
# /vol1/1000/Docker/nginx/conf.d/bls_project_console.conf
|
||||
#
|
||||
# Assumptions (based on your Nginx project layout):
|
||||
# - Host path /vol1/1000/Docker/nginx/sites is mounted into container as /var/www
|
||||
# so this project static files should be copied to:
|
||||
# /vol1/1000/Docker/nginx/sites/bls_project_console/
|
||||
# and will be served from:
|
||||
# /var/www/bls_project_console/
|
||||
# - Backend runs on the HOST at 127.0.0.1:3001.
|
||||
# Nginx container reaches host via host.docker.internal.
|
||||
# On Linux you typically need in nginx docker-compose:
|
||||
# extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name blv-rd.tech;
|
||||
|
||||
root /var/www/bls_project_console;
|
||||
index index.html;
|
||||
|
||||
# API reverse proxy
|
||||
location /api/ {
|
||||
proxy_pass http://host.docker.internal:3001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# SPA history mode
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx-custom/access.log;
|
||||
error_log /var/log/nginx-custom/error.log warn;
|
||||
}
|
||||
23
deploy/systemd/bls-project-console.service
Normal file
23
deploy/systemd/bls-project-console.service
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copy to: /etc/systemd/system/bls-project-console.service
|
||||
# Then: systemctl daemon-reload && systemctl enable --now bls-project-console
|
||||
|
||||
[Unit]
|
||||
Description=BLS Project Console (Node/Express)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/vol1/1000/Docker/nginx/project/bls/bls_project_console
|
||||
|
||||
# Use repo .env (copy .env.example -> .env)
|
||||
EnvironmentFile=/vol1/1000/Docker/nginx/project/bls/bls_project_console/.env
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
# Node 22 is installed on host. If node path differs, replace /usr/bin/node.
|
||||
ExecStart=/usr/bin/node src/backend/server.js
|
||||
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -15,7 +15,6 @@
|
||||
**描述**: 统一存储所有项目的心跳信息,替代原有的分散键结构。
|
||||
|
||||
**数据格式**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
@@ -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连接状态
|
||||
- 项目列表完整性
|
||||
- 日志队列长度
|
||||
|
||||
@@ -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 调用
|
||||
|
||||
|
||||
@@ -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操作次数
|
||||
|
||||
@@ -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
|
||||
@@ -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 验证项目选择功能
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -150,7 +113,7 @@ const isAtBottom = ref(true);
|
||||
let pollTimer = null;
|
||||
|
||||
const mergedLogs = computed(() => {
|
||||
const combined = [ ...remoteLogs.value, ...uiLogs.value ]
|
||||
const combined = [...remoteLogs.value, ...uiLogs.value]
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
if (combined.length <= MAX_LOGS) return combined;
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user