提交 01c44f17 authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim,performance): batch_start_simulators 并行化 + 性能测试项目管理与批量执行

设备模拟模块:
- batch_start_simulators 改为 ThreadPoolExecutor 并行启动,540 台设备从 27 分钟缩短到 ~3 秒
- 每个线程独立 asyncio 事件循环 + 独立 DB 会话,避免同步 HTTP 阻塞和线程安全问题
- asyncio.Semaphore(50) 控制并发,防止 MQTT Broker / Token API 过载
- mqtt_manager on_disconnect 移除阻塞 sleep() 和手动 reconnect()

性能测试模块:
- 新增性能测试项目管理与批量执行功能
- 前端 4 个页面(Projects / ProjectDetail / TaskList / App.vue)
- 后端路由、服务、模型、Schema 适配
- 2 份 PRD 需求文档 + 执行计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3bdacbda
# PRD - 性能测试项目管理与批量执行
> **文档版本**: v1.0
> **创建日期**: 2026-08-20
> **模块类型**: 性能测试
> **优先级**: P1
---
## 一、背景与目标
### 1.1 背景
当前性能测试模块的任务管理采用**平铺列表**方式,所有任务混在一起,缺乏层级组织能力。随着性能测试任务数量增长,用户面临以下问题:
1. **任务归属不清晰**:无法区分「标准版」与「项目定制」场景的任务
2. **无法批量操作**:每次只能执行单个任务,需要反复操作
3. **缺乏项目级视角**:不能直观查看某个项目下所有接口的性能概况
UI 自动化模块已通过 `module_type: standard/custom` 实现了「标准模块」与「项目定制模块」的区分,并支持整模块执行和模块内多选执行。性能测试模块需要类似的层级管理能力。
### 1.2 目标
1. **项目化管理**:将性能测试任务组织到「项目」中,区分标准版/项目定制场景
2. **整项目执行**:一键执行项目下所有任务(顺序执行)
3. **批量勾选执行**:进入项目后,勾选多个任务批量执行
4. **兼容现有数据**:已有任务可归入项目,不破坏现有数据
---
## 二、功能范围
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 性能项目管理(CRUD) | 创建/编辑/删除项目,区分标准版/项目定制 | P0 |
| 项目卡片网格视图 | 类似 Modules.vue 的卡片布局展示项目 | P0 |
| 任务归属项目 | 创建/编辑任务时选择归属项目 | P0 |
| 项目内任务列表 | 进入项目后查看该项目的所有任务 | P0 |
| 整项目执行 | 一键顺序执行项目下所有任务 | P0 |
| 多任务勾选批量执行 | 勾选多个任务后批量执行(顺序执行) | P0 |
| 批量执行进度展示 | 展示当前执行到第几个任务、整体进度 | P1 |
| 任务列表按项目筛选 | 全局任务列表支持按项目维度过滤 | P1 |
| 项目维度统计 | 项目卡片展示任务数、最近执行状态等 | P1 |
---
## 三、数据模型设计
### 3.1 PerformanceProject(新表)
```sql
CREATE TABLE performance_projects (
id VARCHAR(64) PRIMARY KEY, -- 唯一标识 (proj_xxx)
name VARCHAR(200) NOT NULL, -- 项目名称
description TEXT DEFAULT '', -- 项目描述
project_type VARCHAR(20) DEFAULT 'standard', -- 类型: standard(标准)/custom(项目定制)
icon VARCHAR(50) DEFAULT 'folder', -- 项目图标
sort_order INT DEFAULT 0, -- 排序序号
created_at DATETIME, -- 创建时间
updated_at DATETIME -- 更新时间
);
```
### 3.2 PerformanceTask(扩展字段)
在现有 `performance_tasks` 表的基础上新增**一个字段**
```sql
ALTER TABLE performance_tasks ADD COLUMN project_id VARCHAR(64) DEFAULT NULL;
```
**说明**
- `project_id` 为 nullable,不强制所有任务都归属项目
- 删除项目时,其下任务的 `project_id` 置为 NULL(不级联删除任务)
- 现有任务不受影响,迁移时用户可手动将任务归入项目
### 3.3 批量执行记录(可选,P1)
```sql
CREATE TABLE performance_batch_executions (
id VARCHAR(64) PRIMARY KEY, -- 批量执行ID
project_id VARCHAR(64), -- 关联项目ID
task_ids TEXT, -- 执行的任务ID列表(JSON数组)
status VARCHAR(20) DEFAULT 'running', -- running/completed/partially_failed
current_index INT DEFAULT 0, -- 当前执行到第几个
total_count INT DEFAULT 0, -- 总任务数
success_count INT DEFAULT 0, -- 成功数
fail_count INT DEFAULT 0, -- 失败数
started_at DATETIME, -- 开始时间
completed_at DATETIME, -- 完成时间
created_at DATETIME -- 创建时间
);
```
---
## 四、API 设计
### 4.1 项目管理
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/api/performance/projects` | 获取项目列表(支持 project_type 筛选) |
| POST | `/api/performance/projects` | 创建项目 |
| GET | `/api/performance/projects/{id}` | 获取项目详情 |
| PUT | `/api/performance/projects/{id}` | 更新项目 |
| DELETE | `/api/performance/projects/{id}` | 删除项目(任务置空 project_id) |
| GET | `/api/performance/projects/{id}/stats` | 获取项目统计(任务数、各状态分布) |
### 4.2 任务列表扩展
| 参数 | 描述 |
|------|------|
| `project_id` | 按项目筛选任务(现有 `list_tasks` 新增参数) |
| `project_id=null` | 查询未归属项目的任务 |
### 4.3 批量执行
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/api/performance/projects/{id}/run-all` | 执行项目下所有任务 |
| POST | `/api/performance/tasks/batch-run` | 批量执行指定任务列表 |
**批量执行流程**
1. 接收请求,返回 `batch_id``task_ids` 列表
2. 后台按顺序逐个执行任务(串行,避免资源竞争)
3. 每个任务执行完毕后更新进度
4. 全部完成后推送批量执行完成通知
5. 可通过 WebSocket 订阅批量执行进度(`perf_batch_progress`
### 4.4 WebSocket 消息扩展
```json
{
"type": "perf_batch_progress",
"batch_id": "batch_xxx",
"project_id": "proj_xxx",
"current_index": 2,
"total_count": 5,
"current_task_id": "perf_xxx",
"current_task_name": "获取设备列表",
"current_status": "completed",
"success_count": 2,
"fail_count": 0
}
```
---
## 五、前端页面设计
### 5.1 页面结构
```
/performance
├── /performance/projects ← 项目列表页(卡片网格,新增)
├── /performance/projects/:id ← 项目详情页(项目内任务列表,新增)
├── /performance/tasks ← 全局任务列表(现有,新增 project_id 筛选)
├── /performance/monitor ← 监控页(现有)
├── /performance/report ← 报告页(现有)
└── /performance/presets ← 预设配置(现有)
```
### 5.2 项目列表页(/performance/projects)
- **顶部 tab**:标准版 / 项目定制(类似 Modules.vue 的 `:type` 参数)
- **卡片网格**:每行 4 张卡片(el-col span=6)
- 显示项目名称、描述、图标
- 显示统计信息:任务数、最近执行状态
- 操作:进入项目(点击卡片)、编辑、删除
- **工具栏**:新建项目按钮、刷新按钮
### 5.3 项目详情页(/performance/projects/:id)
- **顶部区域**:项目名称、描述、返回按钮
- **工具栏**
- 「执行全部」按钮(一键执行项目下所有任务)
- 批量执行按钮(勾选后出现)
- 新建任务按钮(自动设置 project_id)
- 状态筛选、刷新
- **任务表格**:在现有 TaskList 表格基础上增加:
- 多选框列(用于批量勾选)
- 显示任务状态、TPS、错误率等
- **批量执行进度条**:执行中显示进度(当前第 N 个 / 共 M 个)
### 5.4 任务创建/编辑弹窗扩展
- 新增「归属项目」下拉选择器(可选,不强制)
- 在现有创建弹窗中增加一个字段
### 5.5 全局任务列表调整
- 任务列表页增加「项目」列,显示项目名称
- 增加按项目筛选的下拉选择器
---
## 六、UI 预览图
> 详见同目录下 `_PRD_性能测试项目管理与批量执行_UI预览.html`
> 可使用浏览器打开查看完整页面设计
---
## 七、非功能需求
### 7.1 兼容性
- 新增 `project_id` 字段为 nullable,现有任务不强制迁移
- 删除项目不级联删除任务,仅置空 `project_id`
- 所有现有 API 保持向后兼容
### 7.2 性能约束
- 批量执行采用**串行**方式(一个任务完成后才执行下一个),避免多个压测引擎同时运行导致资源竞争
- 批量执行进度通过 WebSocket 实时推送
### 7.3 安全性
- 正在运行的任务不可编辑/删除(现有约束)
- 批量执行中如果某个任务失败,继续执行下一个(不中断)
---
## 八、实施计划
| 阶段 | 内容 | 预估工时 |
|------|------|---------|
| Phase 1 | 数据模型 + 后端 CRUD API | 1 天 |
| Phase 2 | 项目列表页 + 项目详情页 UI | 1 天 |
| Phase 3 | 批量执行 + 进度推送 | 1 天 |
| Phase 4 | 现有任务列表适配 + 联调测试 | 0.5 天 |
---
*本文档完成后,将使用 prd-plan skill 生成计划执行文档,再使用 prd-code skill 实现代码。*
\ No newline at end of file
# 执行计划 - 性能测试项目管理与批量执行
> **关联 PRD**:`_PRD_性能测试项目管理与批量执行.md`
> **创建日期**:2026-08-20
> **预计总工时**:3.5 天
---
## 一、执行概述
本计划将 PRD 中的「性能测试项目管理与批量执行」功能分为 4 个 Phase 实施:
1. **Phase 1**:数据模型 + 后端 CRUD API(1 天)
2. **Phase 2**:项目列表页 + 项目详情页 UI(1 天)
3. **Phase 3**:批量执行 + 进度推送(1 天)
4. **Phase 4**:现有任务列表适配 + 联调测试(0.5 天)
每个 Phase 完成后可独立验收,Phase 1 是后续所有 Phase 的基础。
---
## 二、任务分解
### Phase 1:数据模型 + 后端 CRUD API
**目标**:新建 PerformanceProject 模型,扩展 PerformanceTask 加 project_id 字段,实现项目 CRUD API。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 1.1 | 新建 `PerformanceProject` ORM 模型 | `backend/app/models/performance.py` | 表字段与 PRD 3.1 一致(id/name/description/project_type/icon/sort_order/created_at/updated_at) |
| 1.2 | 扩展 `PerformanceTask` 模型增加 `project_id` 字段 | `backend/app/models/performance.py` | project_id VARCHAR(64) DEFAULT NULL,to_dict() 输出含 project_id |
| 1.3 | 数据库迁移脚本(SQLite ALTER TABLE) | `backend/scripts/migrate_add_perf_project.py` | 运行后 performance_projects 表存在,performance_tasks 有 project_id 列 |
| 1.4 | 新建 Project CRUD schemas | `backend/app/schemas/performance.py` | PerformanceProjectCreate/Update/Response,camelCase alias |
| 1.5 | 新建 Project CRUD 路由 + Service | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | GET/POST/PUT/DELETE /api/performance/projects 全部可用 |
| 1.6 | 项目统计接口 | 同上 | GET /api/performance/projects/{id}/stats 返回任务数、状态分布 |
| 1.7 | list_tasks 增加 project_id 筛选参数 | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | 传 project_id 筛选、传 null 查未归属任务、不传返回全部(向后兼容) |
| 1.8 | 创建/更新任务 schema 增加 project_id 字段 | `backend/app/schemas/performance.py` | PerformanceTaskCreate/Update 含可选 project_id |
**验收方式**:通过 http://localhost:8001/docs 的 Swagger UI 手动测试所有项目 CRUD 接口。
---
### Phase 2:项目列表页 + 项目详情页 UI
**目标**:新建两个前端页面,实现项目卡片网格和项目内任务列表。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 2.1 | 新增 PerformanceProject 类型定义 | `frontend/src/types/performance.ts` | 接口与后端 schema 对应 |
| 2.2 | 新增项目 API 调用函数 | `frontend/src/api/performance.ts` | projects CRUD + stats + listTasks(projectId) |
| 2.3 | 新建项目列表页 `Projects.vue` | `frontend/src/views/performance/Projects.vue` | 卡片网格布局,顶部 tab(标准版/项目定制),每行 4 张卡片,含编辑/删除操作 |
| 2.4 | 新建项目详情页 `ProjectDetail.vue` | `frontend/src/views/performance/ProjectDetail.vue` | 顶部项目信息,工具栏(执行全部/新建任务),任务表格(含 checkbox 列) |
| 2.5 | 注册路由 | `frontend/src/router/index.ts` | /performance/projects 和 /performance/projects/:id 可访问 |
| 2.6 | 侧边栏菜单更新 | `frontend/src/App.vue` 或菜单配置 | 性能测试下出现「项目管理」子菜单入口 |
**验收方式**:浏览器访问 /performance/projects,能看到卡片网格;点击卡片进入详情页,能看到任务列表。
---
### Phase 3:批量执行 + 进度推送
**目标**:实现整项目执行和多任务批量执行,通过 WebSocket 推送进度。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 3.1 | 新建批量执行记录模型(可选 P1) | `backend/app/models/performance.py` | performance_batch_executions 表创建 |
| 3.2 | 实现 run-all API(项目下所有任务串行执行) | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | POST /api/performance/projects/{id}/run-all 返回 batch_id |
| 3.3 | 实现 batch-run API(指定任务列表串行执行) | 同上 | POST /api/performance/tasks/batch-run 接收 task_ids 数组 |
| 3.4 | WebSocket 批量进度消息 | `backend/app/websocket/manager.py` | 消息类型 perf_batch_progress,含 batch_id/current_index/total_count 等 |
| 3.5 | 前端「执行全部」按钮逻辑 | `ProjectDetail.vue` | 点击后调用 run-all,监听 WebSocket 显示进度条 |
| 3.6 | 前端「批量执行」按钮逻辑 | `ProjectDetail.vue` | 勾选任务后出现批量执行按钮,点击调用 batch-run,显示进度条 |
| 3.7 | 批量执行进度 UI 组件 | `ProjectDetail.vue` | 进度条显示「当前第 N 个 / 共 M 个」,成功/失败计数 |
**验收方式**:在项目详情页勾选多个任务,点击批量执行,观察进度条实时更新;点击「执行全部」同理。
---
### Phase 4:现有列表适配 + 联调测试
**目标**:全局任务列表增加项目列和筛选,整体联调。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 4.1 | 全局任务列表增加「归属项目」列 | `frontend/src/views/performance/TaskList.vue` | 表格显示项目名称(el-tag) |
| 4.2 | 全局任务列表增加项目筛选下拉 | `frontend/src/views/performance/TaskList.vue` | 下拉选择项目可筛选,选「全部」返回所有 |
| 4.3 | 创建/编辑任务弹窗增加项目选择 | `frontend/src/views/performance/TaskList.vue` 或组件 | 弹窗中有「归属项目」下拉选择器 |
| 4.4 | 整体联调 | 全部相关文件 | 创建项目 → 创建任务归入项目 → 项目详情页查看 → 批量执行 → 全局列表筛选均正常 |
| 4.5 | 数据迁移验证 | `backend/scripts/migrate_add_perf_project.py` | 已有数据不受影响,project_id 为 NULL |
**验收方式**:端到端流程——创建项目、创建任务关联项目、在项目详情页批量执行、在全局列表按项目筛选。
---
## 三、验收标准总览
| 验收项 | 对应 Phase |
|--------|-----------|
| 项目 CRUD API 全部可用 | Phase 1 |
| list_tasks 支持 project_id 筛选且向后兼容 | Phase 1 |
| 项目列表页卡片网格正常展示 | Phase 2 |
| 项目详情页任务列表正常展示 | Phase 2 |
| 整项目执行(run-all)串行执行完成 | Phase 3 |
| 多任务批量执行(batch-run)串行执行完成 | Phase 3 |
| WebSocket 实时推送批量进度 | Phase 3 |
| 全局任务列表显示项目列 + 支持筛选 | Phase 4 |
| 创建/编辑任务可选择归属项目 | Phase 4 |
| 已有数据不被破坏 | Phase 1, 4 |
---
## 四、测试计划
### 单元测试
- Phase 1 完成后:Project CRUD Service 测试
- Phase 3 完成后:批量执行逻辑测试
### 集成测试
- Phase 4 完成后:端到端流程测试
- 创建项目 → 创建任务 → 关联项目 → 批量执行 → 查看结果
### 手动测试
- 各 Phase 完成后通过 Swagger UI + 前端页面手动验证
---
## 五、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 批量执行串行耗时长 | 用户等待时间长 | WebSocket 实时推送进度,支持中途取消(可选) |
| 多任务执行资源竞争 | 压测引擎互相干扰 | 严格串行,一个完成才执行下一个 |
| 数据库锁(SQLite) | 并发写入失败 | 利用现有 pool_pre_ping + check_same_thread 配置 |
| 现有 API 兼容性 | 前端调用异常 | project_id 为 nullable,不传时返回全部(向后兼容) |
---
## 六、后续工作
- [ ] Phase 1-4 实施完成后更新 CLAUDE.md 当前进度
- [ ] 更新 HANDOFF.md 交接文档
- [ ] Git 提交(Conventional Commits)
- [ ] 部署到 5.60 服务器(如需要)
---
## 七、附录
### 关键数据流
```
创建项目 → POST /api/performance/projects
创建任务(带 project_id)→ POST /api/performance/tasks
项目详情页 → GET /api/performance/projects/{id} + list_tasks(project_id)
批量执行 → POST /api/performance/tasks/batch-run
WebSocket 进度推送 ← perf_batch_progress
```
### 参考文件
| 文件 | 用途 |
|------|------|
| `frontend/src/views/modules/Modules.vue` | 卡片网格布局参考 |
| `frontend/src/api/modules.ts` | API 调用模式参考 |
| `backend/app/services/module_service.py` | Service 层 CRUD 参考 |
| `backend/app/models/module.py` | Module 模型参考(module_type) |
| `backend/app/services/performance_service.py` | 现有性能服务(_run_sync 串行执行逻辑) |
---
*本文档由 prd-plan skill 生成,关联 PRD:`_PRD_性能测试项目管理与批量执行.md`*
...@@ -879,6 +879,109 @@ if existing and existing.connected and existing.client: ...@@ -879,6 +879,109 @@ if existing and existing.connected and existing.client:
--- ---
### 2026-08-20 会话 J:修复全部启动功能失败 — token_api_host 错误回退 + MQTT on_disconnect 阻塞修复(代码修复 + 部署验证完成)
**会话目标**:修复"全部启动"功能一直启动不起来的问题
**状态**:✅ 问题分析 + 执行计划文档完成;✅ 代码修复(token_api_host 回退 + mqtt_manager on_disconnect 阻塞);✅ 部署到 192.168.5.60 并验证通过;⚠️ MQTT 连接闪断(rc=7)为 Broker 端问题,非 app 代码缺陷
---
#### 问题 1:token_api_host 错误回退导致全部启动阻塞
**现象**:点击门口屏或无纸化的"全部启动"按钮后,页面长时间无响应,设备状态不更新。
**根因**`device_sim_service.py` 第 843 行:
```python
# 修复前 — 环境配置未设置 token_api_host 时回退为 broker_host
token_api_host = config.default_topic_params.get("token_api_host") or config.broker_host
```
当环境配置 `default_topic_params` 中不含 `token_api_host`(如 5.60 服务器的环境配置),回退为 MQTT Broker 地址 `192.168.5.44`,门口屏/无纸化模拟器 `start()` 判断 `if not token_host:` 为 False → 进入 token 获取分支 → 调用 Token API 到 `192.168.5.44`(不是 Token API 服务器)→ 每台设备串行失败。
**修复**`device_sim_service.py` 第 843 行):
```python
# 修复后 — 未配置时保持 None,模拟器跳过 token 获取直接走 MQTT
token_api_host = config.default_topic_params.get("token_api_host")
```
---
#### 问题 2:mqtt_manager.py on_disconnect 回调阻塞导致启动卡死
**现象**:修复问题 1 后,小批量启动(4 台)成功,但 MQTT 连接持续闪断(每 ~3 秒连接→断开→重连)。
**根因**`mqtt_manager.py``on_disconnect` 回调中:
```python
# 修复前 — 阻塞 paho 网络循环线程 + 与内置重连冲突
time.sleep(delay) # 阻塞 paho 网络线程
client.reconnect() # 与 reconnect_delay_set 自动重连冲突
```
`on_disconnect` 运行在 paho 的网络循环线程上,`time.sleep()` 阻塞该线程导致:
1. paho 的 `reconnect_delay_set(min_delay=1, max_delay=60)` 内置自动重连被阻塞
2. 手动 `reconnect()` 与 paho 自动重连同时触发,可能导致重复连接被 Broker 踢掉
**修复**`mqtt_manager.py` `on_disconnect`):
```python
# 修复后 — 不阻塞、不手动重连,由 paho 内置机制处理
def on_disconnect(self, client, userdata, rc):
with self._lock:
self.connected = False
if rc != 0:
logger.warning(f"MQTT 意外断开: env_config_id={self.env_config_id}, rc={rc}, 等待自动重连...")
```
---
#### 已知问题:MQTT 连接闪断(rc=7 = MQTT_ERR_CONN_LOST)
经独立测试确认,MQTT 到 192.168.5.44:1883 的连接每 ~3 秒被 Broker 主动断开,与订阅/发布/client_id 均无关。这是 **Broker 端的问题**(如 idle_timeout、duplicate client 检测、ACL kick 等),非 app 代码缺陷。
- 连接 3 秒后断开 → paho 自动重连 → 连接 3 秒后再次断开,循环
- 设备能成功启动(小批量测试 4/4 成功),但 540 台串行全部启动超时(540 × 3s ≈ 27 分钟 > 120s 超时)
- 建议后续:① 排查 Broker 配置 ② batch_start 改为并行启动 ③ 或切换到 5.60 本地 EMQX
---
#### 部署操作
1. 修复 `deploy_door_http_final.py`,补充 `device_sim_service.py` 到上传列表(共 8 个后端文件)
2. 修复 `mqtt_manager.py` on_disconnect 回调
3. SFTP 上传 8 个后端文件到 5.60 服务器 → `docker compose restart app`
4. 容器重启后状态:`Up (healthy)`
---
#### 验证结果
| 验证项 | 结果 |
|--------|------|
| 小批量启动 4 台门口屏 | ✅ 成功 4/4,无阻塞 |
| 小批量启动 6 台门口屏 | ✅ 成功 4/6(2 台 ID 不存在) |
| 540 台全部启动(start-all) | ⚠️ 超时(Broker 闪断 + 串行启动太慢) |
| MQTT 连接稳定性 | ⚠️ 每 ~3 秒断连(Broker 端问题) |
| 设备统计 API | ✅ 正确返回 1040 台设备统计 |
---
**修改文件清单(本次会话)**
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 843 行删除 `or config.broker_host`,token_api_host 未配置时保持 None |
| `backend/app/services/mqtt_manager.py` | 修改 | `on_disconnect` 移除 `time.sleep()` + `client.reconnect()`,由 paho 内置自动重连处理 |
| `deploy_door_http_final.py` | 修改 | BACKEND_FILES 列表新增 `device_sim_service.py`(共 8 个文件) |
| `Docs/PRD/设备模拟/问题处理/问题分析_token_api_host错误回退导致全部启动失败.md` | 新增 | 问题分析文档 |
| `Docs/PRD/设备模拟/执行计划/执行计划_修复token_api_host错误回退导致全部启动失败.md` | 新增 | 执行计划文档 |
**待办**
- ⚠️ 本次改动未提交 git,下次会话 `/GitCommit` 提交
- ⚠️ 未部署到生产环境(5.60 服务器已部署)
- P0:排查 192.168.5.44 Broker 闪断原因(rc=7 = MQTT_ERR_CONN_LOST)
- P1:`batch_start_simulators` 改为并行启动(asyncio.gather + semaphore 控制并发),避免 540 台串行超时
- P2:考虑使用 5.60 本地 EMQX 替代 192.168.5.44 的 Broker
---
### 2026-08-20 会话 H:修复部署遗漏导致门口屏启动失败(已全部完成并部署) ### 2026-08-20 会话 H:修复部署遗漏导致门口屏启动失败(已全部完成并部署)
**会话目标**:修复门口屏启动报错 `BaseSimulator.__init__() takes from 5 to 8 positional arguments but 9 were given`,因 `deploy_door_http_final.py` 部署时遗漏 `base_simulator.py` 所致。 **会话目标**:修复门口屏启动报错 `BaseSimulator.__init__() takes from 5 to 8 positional arguments but 9 were given`,因 `deploy_door_http_final.py` 部署时遗漏 `base_simulator.py` 所致。
......
...@@ -191,6 +191,9 @@ async def _ensure_columns(conn) -> None: ...@@ -191,6 +191,9 @@ async def _ensure_columns(conn) -> None:
("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"), ("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"),
("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"), ("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"),
("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"), ("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:任务归属项目ID(旧库升级)
("performance_tasks", "project_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录)
] ]
def _do_ensure(sync_conn) -> None: def _do_ensure(sync_conn) -> None:
......
...@@ -17,6 +17,57 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship ...@@ -17,6 +17,57 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
class PerformanceProject(Base):
"""
性能测试项目数据库模型
用于组织性能测试任务,区分标准版/项目定制场景。
Attributes:
id (str): 项目唯一标识 (proj_xxx)
name (str): 项目名称
description (str): 项目描述
project_type (str): 类型: standard(标准)/custom(项目定制)
icon (str): 项目图标
sort_order (int): 排序序号
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
"""
__tablename__ = "performance_projects"
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="项目唯一标识")
name: Mapped[str] = mapped_column(String(200), default="", comment="项目名称")
description: Mapped[str] = mapped_column(Text, default="", comment="项目描述")
project_type: Mapped[str] = mapped_column(
String(20), default="standard", comment="类型: standard(标准)/custom(项目定制)"
)
icon: Mapped[str] = mapped_column(String(50), default="folder", comment="项目图标")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序序号")
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
def __repr__(self) -> str:
return f"<PerformanceProject(id={self.id}, name={self.name}, type={self.project_type})>"
def to_dict(self) -> dict:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"project_type": self.project_type,
"icon": self.icon,
"sort_order": self.sort_order,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class PerformanceTask(Base): class PerformanceTask(Base):
""" """
性能测试任务数据库模型 性能测试任务数据库模型
...@@ -28,6 +79,7 @@ class PerformanceTask(Base): ...@@ -28,6 +79,7 @@ class PerformanceTask(Base):
name (str): 任务名称 name (str): 任务名称
description (str): 任务描述 description (str): 任务描述
status (str): 状态 (pending/running/completed/failed/cancelled) status (str): 状态 (pending/running/completed/failed/cancelled)
project_id (str): 归属项目ID (nullable)
# 目标配置 # 目标配置
target_url (str): 目标接口完整 URL target_url (str): 目标接口完整 URL
...@@ -84,6 +136,9 @@ class PerformanceTask(Base): ...@@ -84,6 +136,9 @@ class PerformanceTask(Base):
comment="状态: pending/running/completed/failed/cancelled" comment="状态: pending/running/completed/failed/cancelled"
) )
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="错误信息") error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="错误信息")
project_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, default=None, comment="归属项目ID"
)
# 目标配置 # 目标配置
target_url: Mapped[str] = mapped_column(Text, default="", comment="目标接口完整URL") target_url: Mapped[str] = mapped_column(Text, default="", comment="目标接口完整URL")
...@@ -180,6 +235,7 @@ class PerformanceTask(Base): ...@@ -180,6 +235,7 @@ class PerformanceTask(Base):
"description": self.description, "description": self.description,
"status": self.status, "status": self.status,
"error_message": self.error_message, "error_message": self.error_message,
"project_id": self.project_id,
"target_url": self.target_url, "target_url": self.target_url,
"method": self.method, "method": self.method,
"headers": self.headers or {}, "headers": self.headers or {},
......
...@@ -34,6 +34,13 @@ from app.schemas.performance import ( ...@@ -34,6 +34,13 @@ from app.schemas.performance import (
PerformanceReportMetrics, PerformanceReportMetrics,
CurlParseRequest, CurlParseRequest,
CurlParseResponse, CurlParseResponse,
PerformanceProjectCreate,
PerformanceProjectUpdate,
PerformanceProjectResponse,
PerformanceProjectListResponse,
PerformanceProjectStatsResponse,
BatchRunRequest,
BatchRunResponse,
) )
from app.services.performance_service import PerformanceService from app.services.performance_service import PerformanceService
from app.utils.curl_parser import parse_curl from app.utils.curl_parser import parse_curl
...@@ -59,10 +66,25 @@ async def list_tasks( ...@@ -59,10 +66,25 @@ async def list_tasks(
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
status: Optional[str] = Query(None, description="按状态过滤"), status: Optional[str] = Query(None, description="按状态过滤"),
project_id: Optional[str] = Query(None, description="按项目过滤;传 null 查未归属任务,不传返回全部"),
service: PerformanceService = Depends(get_perf_service), service: PerformanceService = Depends(get_perf_service),
): ):
"""获取性能测试任务列表(分页)""" """
items, total = await service.list_tasks(page=page, page_size=page_size, status=status) 获取性能测试任务列表(分页)
- 不传 project_id:返回全部任务(向后兼容)
- project_id=null / empty:返回未归属任何项目的任务
- project_id=xxx:返回指定项目下的任务
"""
_project_id = None
if project_id == "null" or project_id == "":
_project_id = ""
else:
_project_id = project_id
items, total = await service.list_tasks(
page=page, page_size=page_size, status=status, project_id=_project_id
)
return PerformanceTaskListResponse( return PerformanceTaskListResponse(
total=total, total=total,
items=[PerformanceTaskListItem(**t.to_dict()) for t in items], items=[PerformanceTaskListItem(**t.to_dict()) for t in items],
...@@ -327,4 +349,165 @@ async def websocket_monitor(websocket: WebSocket, task_id: str): ...@@ -327,4 +349,165 @@ async def websocket_monitor(websocket: WebSocket, task_id: str):
except Exception as e: except Exception as e:
logger.error(f"WebSocket 异常: {e}") logger.error(f"WebSocket 异常: {e}")
finally: finally:
await manager.disconnect(websocket, ws_group) await manager.disconnect(websocket, ws_group)
\ No newline at end of file
# ==================== 项目 CRUD ====================
@router.get("/projects", response_model=PerformanceProjectListResponse)
async def list_projects(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(100, ge=1, le=200, description="每页数量"),
project_type: Optional[str] = Query(None, description="按类型筛选: standard/custom"),
service: PerformanceService = Depends(get_perf_service),
):
"""
获取性能测试项目列表(分页)
每个项目附带统计信息:任务数、最近执行状态、最近执行时间。
"""
projects, total = await service.list_projects(page=page, page_size=page_size, project_type=project_type)
items = []
for proj in projects:
proj_dict = proj.to_dict()
# 获取项目统计
stats = await service.get_project_stats(proj.id)
proj_dict["task_count"] = stats["task_count"]
proj_dict["last_run_status"] = stats["last_run_status"]
proj_dict["last_run_time"] = stats["last_run_time"]
items.append(PerformanceProjectResponse(**proj_dict))
return PerformanceProjectListResponse(total=total, items=items)
@router.post("/projects", response_model=PerformanceProjectResponse, status_code=201)
async def create_project(
data: PerformanceProjectCreate,
service: PerformanceService = Depends(get_perf_service),
):
"""创建性能测试项目"""
project = await service.create_project(data)
result = project.to_dict()
result["task_count"] = 0
result["last_run_status"] = None
result["last_run_time"] = None
return PerformanceProjectResponse(**result)
@router.get("/projects/{project_id}", response_model=PerformanceProjectResponse)
async def get_project(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""获取性能测试项目详情"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
result = project.to_dict()
stats = await service.get_project_stats(project.id)
result["task_count"] = stats["task_count"]
result["last_run_status"] = stats["last_run_status"]
result["last_run_time"] = stats["last_run_time"]
return PerformanceProjectResponse(**result)
@router.put("/projects/{project_id}", response_model=PerformanceProjectResponse)
async def update_project(
project_id: str,
data: PerformanceProjectUpdate,
service: PerformanceService = Depends(get_perf_service),
):
"""更新性能测试项目"""
project = await service.update_project(project_id, data.model_dump(exclude_unset=True))
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
result = project.to_dict()
stats = await service.get_project_stats(project.id)
result["task_count"] = stats["task_count"]
result["last_run_status"] = stats["last_run_status"]
result["last_run_time"] = stats["last_run_time"]
return PerformanceProjectResponse(**result)
@router.delete("/projects/{project_id}", status_code=204)
async def delete_project(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""删除性能测试项目(不级联删除任务,仅置空 project_id)"""
deleted = await service.delete_project(project_id)
if not deleted:
raise HTTPException(status_code=404, detail="项目不存在")
@router.get("/projects/{project_id}/stats", response_model=PerformanceProjectStatsResponse)
async def get_project_stats(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""获取性能测试项目统计信息"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
stats = await service.get_project_stats(project_id)
return PerformanceProjectStatsResponse(**stats)
# ==================== 批量执行 ====================
@router.post("/tasks/batch-run", response_model=BatchRunResponse)
async def batch_run_tasks(
data: BatchRunRequest,
service: PerformanceService = Depends(get_perf_service),
):
"""
批量执行指定任务列表(串行)
按顺序逐个执行任务,每个任务完成后才执行下一个。
进度通过 WebSocket 推送(perf_batch_progress)。
某个任务失败不影响后续任务执行。
"""
if len(data.task_ids) == 0:
raise HTTPException(status_code=400, detail="任务列表不能为空")
result = await service.batch_run_tasks(
task_ids=data.task_ids,
project_id=data.project_id,
)
return BatchRunResponse(
message=f"批量执行完成: {result['success_count']} 成功, {result['fail_count']} 失败",
batch_id=result["batch_id"],
task_count=result["total"],
)
@router.post("/projects/{project_id}/run-all", response_model=BatchRunResponse)
async def run_project_all(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""
执行项目下所有任务(串行)
按创建时间顺序执行项目下所有 pending/completed/failed/cancelled 状态的任务。
进度通过 WebSocket 推送(perf_batch_progress)。
"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
try:
result = await service.run_project_all(project_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return BatchRunResponse(
message=f"项目执行完成: {result['success_count']} 成功, {result['fail_count']} 失败",
batch_id=result["batch_id"],
task_count=result["total"],
)
\ No newline at end of file
...@@ -54,6 +54,7 @@ class PerformanceTaskCreate(BaseModel): ...@@ -54,6 +54,7 @@ class PerformanceTaskCreate(BaseModel):
"""创建性能测试任务""" """创建性能测试任务"""
name: str = Field(..., min_length=1, max_length=200, description="任务名称") name: str = Field(..., min_length=1, max_length=200, description="任务名称")
description: Optional[str] = Field(None, description="任务描述, 留空使用空串") description: Optional[str] = Field(None, description="任务描述, 留空使用空串")
project_id: Optional[str] = Field(None, description="归属项目ID")
# 目标配置 # 目标配置
target_url: str = Field(..., description="目标接口完整URL") target_url: str = Field(..., description="目标接口完整URL")
...@@ -99,6 +100,7 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -99,6 +100,7 @@ class PerformanceTaskUpdate(BaseModel):
"""更新性能测试任务(所有字段可选)""" """更新性能测试任务(所有字段可选)"""
name: Optional[str] = Field(None, min_length=1, max_length=200, description="任务名称") name: Optional[str] = Field(None, min_length=1, max_length=200, description="任务名称")
description: Optional[str] = Field(None, description="任务描述") description: Optional[str] = Field(None, description="任务描述")
project_id: Optional[str] = Field(None, description="归属项目ID")
target_url: Optional[str] = Field(None, description="目标接口完整URL") target_url: Optional[str] = Field(None, description="目标接口完整URL")
method: Optional[str] = Field(None, description="HTTP方法") method: Optional[str] = Field(None, description="HTTP方法")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
...@@ -133,6 +135,7 @@ class PerformanceTaskResponse(BaseModel): ...@@ -133,6 +135,7 @@ class PerformanceTaskResponse(BaseModel):
description: str = "" description: str = ""
status: str = "pending" status: str = "pending"
error_message: Optional[str] = None error_message: Optional[str] = None
project_id: Optional[str] = None
target_url: str = "" target_url: str = ""
method: str = "GET" method: str = "GET"
headers: Dict[str, str] = {} headers: Dict[str, str] = {}
...@@ -204,6 +207,7 @@ class PerformanceTaskListItem(BaseModel): ...@@ -204,6 +207,7 @@ class PerformanceTaskListItem(BaseModel):
status: str status: str
mode: str mode: str
target_url: str target_url: str
project_id: Optional[str] = None
total_requests: int = 0 total_requests: int = 0
actual_tps: float = 0.0 actual_tps: float = 0.0
peak_tps: float = 0.0 peak_tps: float = 0.0
...@@ -377,4 +381,84 @@ class PerformanceSnapshotListResponse(BaseModel): ...@@ -377,4 +381,84 @@ class PerformanceSnapshotListResponse(BaseModel):
total: int total: int
items: List[PerformanceSnapshotResponse] items: List[PerformanceSnapshotResponse]
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 性能测试项目 ====================
class PerformanceProjectCreate(BaseModel):
"""创建性能测试项目"""
name: str = Field(..., min_length=1, max_length=200, description="项目名称")
description: str = Field("", description="项目描述")
project_type: str = Field("standard", pattern=r"^(standard|custom)$", description="类型: standard/custom")
icon: str = Field("folder", max_length=50, description="项目图标")
sort_order: int = Field(0, description="排序序号")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectUpdate(BaseModel):
"""更新性能测试项目(所有字段可选)"""
name: Optional[str] = Field(None, min_length=1, max_length=200, description="项目名称")
description: Optional[str] = Field(None, description="项目描述")
project_type: Optional[str] = Field(None, pattern=r"^(standard|custom)$", description="类型: standard/custom")
icon: Optional[str] = Field(None, max_length=50, description="项目图标")
sort_order: Optional[int] = Field(None, description="排序序号")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectResponse(BaseModel):
"""性能测试项目响应"""
id: str
name: str
description: str = ""
project_type: str = "standard"
icon: str = "folder"
sort_order: int = 0
# 统计信息(列表接口附带)
task_count: int = 0
last_run_status: Optional[str] = None
last_run_time: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectListResponse(BaseModel):
"""性能测试项目列表响应"""
total: int
items: List[PerformanceProjectResponse]
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectStatsResponse(BaseModel):
"""性能测试项目统计响应"""
project_id: str
task_count: int = 0
status_distribution: Dict[str, int] = Field(default_factory=dict)
last_run_status: Optional[str] = None
last_run_time: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 批量执行 ====================
class BatchRunRequest(BaseModel):
"""批量执行请求"""
task_ids: List[str] = Field(..., min_length=1, description="要执行的任务ID列表")
project_id: Optional[str] = Field(None, description="关联项目ID(可选)")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class BatchRunResponse(BaseModel):
"""批量执行响应"""
message: str
batch_id: str
task_count: int
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
\ No newline at end of file
...@@ -1051,28 +1051,76 @@ class DeviceSimService: ...@@ -1051,28 +1051,76 @@ class DeviceSimService:
# ==================== 批量操作 ==================== # ==================== 批量操作 ====================
async def batch_start_simulators(self, device_ids: List[str]) -> dict: async def batch_start_simulators(self, device_ids: List[str], max_concurrency: int = 50) -> dict:
""" """
批量启动模拟设备 批量启动模拟设备(线程池并行执行,提高大批量启动效率)
使用 run_in_executor + ThreadPoolExecutor 实现真正的并行启动,
每个设备在独立线程中运行,拥有独立的 asyncio 事件循环和数据库会话。
避免 `asyncio.gather` 在同步 HTTP 调用(token API)时阻塞事件循环的问题。
Args: Args:
device_ids: 设备 ID 列表 device_ids: 设备 ID 列表
max_concurrency: 最大并行数(默认 50,防止 Broker/Token API 过载)
Returns: Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str} dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
""" """
success_count = 0 from concurrent.futures import ThreadPoolExecutor
failed_ids = [] from app.database import async_session_maker
for device_id in device_ids: if not device_ids:
return {
"success_count": 0,
"failed_count": 0,
"failed_ids": [],
"message": "没有选择要启动的设备",
}
# 去重,避免同一设备重复启动
unique_ids = list(dict.fromkeys(device_ids))
def _start_one_sync(device_id: str) -> bool:
"""
在独立线程中启动单个设备(同步包装器)
每个线程创建独立的 asyncio 事件循环和数据库会话,
实现真正的并行执行,避免同步 HTTP 调用阻塞事件循环。
"""
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try: try:
result = await self.start_simulator(device_id) async def _run():
if result: async with async_session_maker() as session:
success_count += 1 svc = DeviceSimService(session)
else: result = await svc.start_simulator(device_id)
failed_ids.append(device_id) await session.commit()
return result
return new_loop.run_until_complete(_run())
except Exception as e: except Exception as e:
logger.warning(f"批量启动设备失败: {device_id}, error={e}") logger.warning(f"批量启动设备失败: {device_id}, error={e}")
return False
finally:
new_loop.close()
loop = asyncio.get_event_loop()
semaphore = asyncio.Semaphore(max_concurrency)
# 使用线程池 + 信号量控制并发
with ThreadPoolExecutor(max_workers=max_concurrency, thread_name_prefix="sim_start") as executor:
async def _start_one(device_id: str) -> Tuple[str, bool]:
async with semaphore:
result = await loop.run_in_executor(executor, _start_one_sync, device_id)
return device_id, result
results = await asyncio.gather(*[_start_one(did) for did in unique_ids])
success_count = 0
failed_ids = []
for device_id, success in results:
if success:
success_count += 1
else:
failed_ids.append(device_id) failed_ids.append(device_id)
return { return {
......
...@@ -76,18 +76,12 @@ class MqttConnection: ...@@ -76,18 +76,12 @@ class MqttConnection:
with self._lock: with self._lock:
self.connected = False self.connected = False
if rc != 0: if rc != 0:
# 非主动断开,尝试重连 # 非主动断开,由 paho-mqtt 内置 reconnect_delay_set 自动重连
self.reconnect_attempts += 1 # 注意:不在回调中手动调用 client.reconnect(),避免与内置重连机制冲突
delay = min(2 ** self.reconnect_attempts, 60) # 指数退避,最大 60 秒
logger.warning( logger.warning(
f"MQTT 意外断开: env_config_id={self.env_config_id}, " f"MQTT 意外断开: env_config_id={self.env_config_id}, rc={rc}, "
f"重连尝试={self.reconnect_attempts}, 等待={delay}s" f"等待自动重连..."
) )
time.sleep(delay)
try:
client.reconnect()
except Exception as e:
logger.error(f"MQTT 重连失败: {e}")
else: else:
logger.info(f"MQTT 主动断开: env_config_id={self.env_config_id}") logger.info(f"MQTT 主动断开: env_config_id={self.env_config_id}")
......
...@@ -84,6 +84,7 @@ ...@@ -84,6 +84,7 @@
<el-icon><Odometer /></el-icon> <el-icon><Odometer /></el-icon>
<span>性能测试</span> <span>性能测试</span>
</template> </template>
<el-menu-item index="/performance/projects">项目管理</el-menu-item>
<el-menu-item index="/performance/tasks">任务管理</el-menu-item> <el-menu-item index="/performance/tasks">任务管理</el-menu-item>
<el-menu-item index="/performance/monitor">执行监控</el-menu-item> <el-menu-item index="/performance/monitor">执行监控</el-menu-item>
<el-menu-item index="/performance/report">报告查看</el-menu-item> <el-menu-item index="/performance/report">报告查看</el-menu-item>
...@@ -280,6 +281,7 @@ const DEPLOY_LABELS: Record<string, string> = { ...@@ -280,6 +281,7 @@ const DEPLOY_LABELS: Record<string, string> = {
/** 性能测试子页面标签映射 */ /** 性能测试子页面标签映射 */
const PERFORMANCE_LABELS: Record<string, string> = { const PERFORMANCE_LABELS: Record<string, string> = {
projects: '项目管理',
tasks: '任务管理', tasks: '任务管理',
monitor: '执行监控', monitor: '执行监控',
report: '报告查看', report: '报告查看',
......
...@@ -16,6 +16,13 @@ import type { ...@@ -16,6 +16,13 @@ import type {
PerformanceSnapshotListResponse, PerformanceSnapshotListResponse,
PerformanceReportResponse, PerformanceReportResponse,
CurlParseResult, CurlParseResult,
PerformanceProjectCreate,
PerformanceProjectUpdate,
PerformanceProject,
PerformanceProjectListResponse,
PerformanceProjectStats,
BatchRunRequest,
BatchRunResponse,
} from '@/types/performance' } from '@/types/performance'
const BASE = '/api/performance' const BASE = '/api/performance'
...@@ -27,6 +34,8 @@ export function listTasks(params?: { ...@@ -27,6 +34,8 @@ export function listTasks(params?: {
page?: number page?: number
pageSize?: number pageSize?: number
status?: string status?: string
/** 按项目过滤;传 null 查未归属任务,不传返回全部 */
projectId?: string | null
}): Promise<PerformanceTaskListResponse> { }): Promise<PerformanceTaskListResponse> {
return request.get(`${BASE}/tasks`, { params }) return request.get(`${BASE}/tasks`, { params })
} }
...@@ -158,4 +167,52 @@ export function deletePreset(id: string): Promise<void> { ...@@ -158,4 +167,52 @@ export function deletePreset(id: string): Promise<void> {
/** 解析 curl 命令,返回结构化压测配置 */ /** 解析 curl 命令,返回结构化压测配置 */
export function parseCurl(curlCommand: string): Promise<CurlParseResult> { export function parseCurl(curlCommand: string): Promise<CurlParseResult> {
return request.post(`${BASE}/parse-curl`, { curlCommand }) return request.post(`${BASE}/parse-curl`, { curlCommand })
}
// ==================== 项目 CRUD ====================
/** 获取项目列表 */
export function listProjects(params?: {
page?: number
pageSize?: number
projectType?: string
}): Promise<PerformanceProjectListResponse> {
return request.get(`${BASE}/projects`, { params })
}
/** 获取项目详情 */
export function getProject(id: string): Promise<PerformanceProject> {
return request.get(`${BASE}/projects/${id}`)
}
/** 创建项目 */
export function createProject(data: PerformanceProjectCreate): Promise<PerformanceProject> {
return request.post(`${BASE}/projects`, data)
}
/** 更新项目 */
export function updateProject(id: string, data: PerformanceProjectUpdate): Promise<PerformanceProject> {
return request.put(`${BASE}/projects/${id}`, data)
}
/** 删除项目 */
export function deleteProject(id: string): Promise<void> {
return request.delete(`${BASE}/projects/${id}`)
}
/** 获取项目统计 */
export function getProjectStats(id: string): Promise<PerformanceProjectStats> {
return request.get(`${BASE}/projects/${id}/stats`)
}
// ==================== 批量执行 ====================
/** 批量执行指定任务 */
export function batchRunTasks(data: BatchRunRequest): Promise<BatchRunResponse> {
return request.post(`${BASE}/tasks/batch-run`, data)
}
/** 执行项目下所有任务 */
export function runProjectAll(projectId: string): Promise<BatchRunResponse> {
return request.post(`${BASE}/projects/${projectId}/run-all`)
} }
\ No newline at end of file
...@@ -163,6 +163,18 @@ const routes: RouteRecordRaw[] = [ ...@@ -163,6 +163,18 @@ const routes: RouteRecordRaw[] = [
redirect: '/performance/tasks', redirect: '/performance/tasks',
meta: { title: '性能测试' }, meta: { title: '性能测试' },
children: [ children: [
{
path: 'projects',
name: 'PerfProjects',
component: () => import('@/views/performance/Projects.vue'),
meta: { title: '项目管理' }
},
{
path: 'projects/:id',
name: 'PerfProjectDetail',
component: () => import('@/views/performance/ProjectDetail.vue'),
meta: { title: '项目详情' }
},
{ {
path: 'tasks', path: 'tasks',
name: 'PerfTasks', name: 'PerfTasks',
......
...@@ -33,6 +33,9 @@ export interface UniqueFieldRule { ...@@ -33,6 +33,9 @@ export interface UniqueFieldRule {
template?: string | null template?: string | null
} }
/** 项目类型 */
export type PerfProjectType = 'standard' | 'custom'
/** 性能测试任务 */ /** 性能测试任务 */
export interface PerformanceTask { export interface PerformanceTask {
id: string id: string
...@@ -40,6 +43,7 @@ export interface PerformanceTask { ...@@ -40,6 +43,7 @@ export interface PerformanceTask {
description: string | null description: string | null
status: PerfStatus status: PerfStatus
errorMessage: string | null errorMessage: string | null
projectId: string | null
targetUrl: string targetUrl: string
method: string method: string
headers: Record<string, string> | null headers: Record<string, string> | null
...@@ -100,6 +104,7 @@ export interface PerformanceTask { ...@@ -100,6 +104,7 @@ export interface PerformanceTask {
export interface PerformanceTaskCreate { export interface PerformanceTaskCreate {
name: string name: string
description?: string | null description?: string | null
projectId?: string | null
targetUrl: string targetUrl: string
method?: string method?: string
headers?: Record<string, string> | null headers?: Record<string, string> | null
...@@ -125,6 +130,7 @@ export interface PerformanceTaskCreate { ...@@ -125,6 +130,7 @@ export interface PerformanceTaskCreate {
export interface PerformanceTaskUpdate { export interface PerformanceTaskUpdate {
name?: string name?: string
description?: string | null description?: string | null
projectId?: string | null
targetUrl?: string targetUrl?: string
method?: string method?: string
headers?: Record<string, string> | null headers?: Record<string, string> | null
...@@ -152,6 +158,7 @@ export interface PerformanceTaskListItem { ...@@ -152,6 +158,7 @@ export interface PerformanceTaskListItem {
name: string name: string
description: string | null description: string | null
status: PerfStatus status: PerfStatus
projectId: string | null
targetUrl: string targetUrl: string
method: string method: string
mode: PerfMode mode: PerfMode
...@@ -385,4 +392,83 @@ export interface CurlParseResult { ...@@ -385,4 +392,83 @@ export interface CurlParseResult {
signRequest: boolean signRequest: boolean
cookie: string | null cookie: string | null
errors: string[] errors: string[]
}
// ==================== 性能测试项目 ====================
/** 性能测试项目 */
export interface PerformanceProject {
id: string
name: string
description: string
projectType: PerfProjectType
icon: string
sortOrder: number
taskCount: number
lastRunStatus: string | null
lastRunTime: string | null
createdAt: string | null
updatedAt: string | null
}
/** 创建项目请求 */
export interface PerformanceProjectCreate {
name: string
description?: string
projectType?: PerfProjectType
icon?: string
sortOrder?: number
}
/** 更新项目请求 */
export interface PerformanceProjectUpdate {
name?: string
description?: string
projectType?: PerfProjectType
icon?: string
sortOrder?: number
}
/** 项目列表响应 */
export interface PerformanceProjectListResponse {
total: number
items: PerformanceProject[]
}
/** 项目统计 */
export interface PerformanceProjectStats {
projectId: string
taskCount: number
statusDistribution: Record<string, number>
lastRunStatus: string | null
lastRunTime: string | null
}
// ==================== 批量执行 ====================
/** 批量执行请求 */
export interface BatchRunRequest {
taskIds: string[]
projectId?: string | null
}
/** 批量执行响应 */
export interface BatchRunResponse {
message: string
batchId: string
taskCount: number
}
/** WebSocket 批量执行进度消息 */
export interface PerfBatchProgress {
type: 'perf_batch_progress'
batchId: string
projectId: string | null
currentIndex: number
totalCount: number
currentTaskId: string
currentTaskName: string
currentStatus: 'running' | 'completed' | 'failed'
successCount: number
failCount: number
} }
\ No newline at end of file
此差异已折叠。
此差异已折叠。
...@@ -20,12 +20,22 @@ ...@@ -20,12 +20,22 @@
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" /> <el-option label="已取消" value="cancelled" />
</el-select> </el-select>
<el-select v-model="projectFilter" placeholder="全部项目" clearable style="width: 160px" @change="loadTasks">
<el-option label="全部项目" value="" />
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-button @click="loadTasks"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadTasks"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
<el-card shadow="never"> <el-card shadow="never">
<el-table :data="tasks" v-loading="loading" stripe style="width: 100%"> <el-table :data="tasks" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="name" label="任务名称" min-width="160" show-overflow-tooltip /> <el-table-column prop="name" label="任务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="所属项目" width="130" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.projectId" size="small">{{ projectName(row.projectId) }}</el-tag>
<span v-else style="color: #909399; font-size: 12px;">未归属</span>
</template>
</el-table-column>
<el-table-column prop="targetUrl" label="目标 URL" min-width="200" show-overflow-tooltip /> <el-table-column prop="targetUrl" label="目标 URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" /> <el-table-column prop="method" label="方法" width="80" />
<el-table-column label="模式" width="90"> <el-table-column label="模式" width="90">
...@@ -109,6 +119,15 @@ ...@@ -109,6 +119,15 @@
<el-input v-model="form.name" placeholder="输入任务名称" /> <el-input v-model="form.name" placeholder="输入任务名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8">
<el-form-item label="归属项目">
<el-select v-model="form.projectId" placeholder="选择项目(可留空)" clearable style="width: 100%">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="接口预设"> <el-form-item label="接口预设">
<el-select <el-select
...@@ -353,8 +372,8 @@ import { ref, onMounted, reactive, computed } from 'vue' ...@@ -353,8 +372,8 @@ import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Refresh, Upload, Delete } from '@element-plus/icons-vue' import { Plus, Refresh, Upload, Delete } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl } from '@/api/performance' import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl, listProjects } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule } from '@/types/performance' import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule, PerformanceProject } from '@/types/performance'
const router = useRouter() const router = useRouter()
...@@ -364,6 +383,10 @@ const total = ref(0) ...@@ -364,6 +383,10 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const statusFilter = ref('') const statusFilter = ref('')
const projectFilter = ref('')
// 项目相关(用于筛选列和归属选择器)
const projects = ref<PerformanceProject[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingTask = ref<PerformanceTask | null>(null) const editingTask = ref<PerformanceTask | null>(null)
...@@ -404,6 +427,7 @@ interface TaskForm { ...@@ -404,6 +427,7 @@ interface TaskForm {
assertions: any[] assertions: any[]
presetId: string presetId: string
uniqueFields: UniqueFieldRule[] uniqueFields: UniqueFieldRule[]
projectId: string
} }
const defaultForm = (): TaskForm => ({ const defaultForm = (): TaskForm => ({
...@@ -429,6 +453,7 @@ const defaultForm = (): TaskForm => ({ ...@@ -429,6 +453,7 @@ const defaultForm = (): TaskForm => ({
assertions: [], assertions: [],
presetId: '', presetId: '',
uniqueFields: [], uniqueFields: [],
projectId: '',
}) })
const form = reactive<TaskForm>(defaultForm()) const form = reactive<TaskForm>(defaultForm())
...@@ -456,6 +481,7 @@ async function loadTasks() { ...@@ -456,6 +481,7 @@ async function loadTasks() {
try { try {
const params: any = { page: page.value, pageSize: pageSize.value } const params: any = { page: page.value, pageSize: pageSize.value }
if (statusFilter.value) params.status = statusFilter.value if (statusFilter.value) params.status = statusFilter.value
if (projectFilter.value) params.projectId = projectFilter.value
const res = await listTasks(params) const res = await listTasks(params)
tasks.value = res.items tasks.value = res.items
total.value = res.total total.value = res.total
...@@ -466,12 +492,29 @@ async function loadTasks() { ...@@ -466,12 +492,29 @@ async function loadTasks() {
} }
} }
/** 加载项目列表(用于下拉筛选和归属选择器) */
async function loadProjects() {
try {
const res = await listProjects({ page: 1, pageSize: 200 })
projects.value = res.items || []
} catch {
projects.value = []
}
}
/** 根据项目 ID 获取项目名称 */
function projectName(id: string | null) {
if (!id) return ''
return projects.value.find(p => p.id === id)?.name || ''
}
/** 打开新建弹窗 */ /** 打开新建弹窗 */
async function openCreateDialog() { async function openCreateDialog() {
editingTask.value = null editingTask.value = null
selectedPreset.value = null selectedPreset.value = null
Object.assign(form, defaultForm()) Object.assign(form, defaultForm())
await loadPresets() await loadPresets()
await loadProjects()
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -490,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -490,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
// 加载预设列表 // 加载预设列表
await loadPresets() await loadPresets()
await loadProjects()
// 如果任务有 presetId,找到对应的预设并回填 // 如果任务有 presetId,找到对应的预设并回填
const presetId = fullTask.presetId || '' const presetId = fullTask.presetId || ''
...@@ -534,6 +578,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -534,6 +578,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
assertions: fullTask.assertions || [], assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '', presetId: presetId && selectedPreset.value ? presetId : '',
uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })), uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })),
projectId: fullTask.projectId || '',
}) })
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -717,6 +762,7 @@ async function handleSave() { ...@@ -717,6 +762,7 @@ async function handleSave() {
const data: any = { const data: any = {
name: form.name, name: form.name,
description: form.description || null, description: form.description || null,
projectId: form.projectId || null,
targetUrl: form.targetUrl, targetUrl: form.targetUrl,
method: form.method, method: form.method,
headers: form.headers ? JSON.parse(form.headers) : null, headers: form.headers ? JSON.parse(form.headers) : null,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论