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

feat(performance): WebSocket 批量进度订阅端点 + 项目详情实时进度条

- 后端新增 /ws/batch/{batch_id} 端点:订阅 perf_batch_{batch_id} 组,
  连接时从 DB 推送当前批次状态快照,ping/pong 保活
- performance_service 新增 _broadcast_progress():同时推送全局 perf_batch
  与批次专属 perf_batch_{batch_id} 两组
- 前端新增 createPerfBatchStream() WS 连接封装
- ProjectDetail.vue 批量执行实时进度条闭环(连接/刷新/完成自动刷列表/关闭断开)
- types/performance.ts PerfBatchProgress 补 partially_failed/cancelled 类型
- TaskList.vue 文案回归纯中文,仅保留 TPS/Error Rate 英文+中文
- 新增场景测试数据 burst_task.json / endurance_task.json + FieldTip 帮助组件
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 6ab2c268
# HANDOFF_性能测试 # HANDOFF_性能测试
> 生成时间:2026-08-12 15:00 > 生成时间:2026-08-12 15:00
> 最后更新:2026-08-22 15:40 > 最后更新:2026-08-24 10:00
> 当前分支:`platform-auto-test` > 当前分支:`platform-auto-test`
> 最近提交:`21d65661 docs(device-sim): 更新 HANDOFF 记录设备模拟模块部署到 5.60` > 最近提交:`21d65661 docs(device-sim): 更新 HANDOFF 记录设备模拟模块部署到 5.60`
> 主分支:`master` > 主分支:`master`
...@@ -22,13 +22,49 @@ ...@@ -22,13 +22,49 @@
| 批量执行合并报告 | ✅ 已部署 | 项目批量/一键执行生成一份合并报告(聚合指标 + 各任务明细)(2026-08-20) | | 批量执行合并报告 | ✅ 已部署 | 项目批量/一键执行生成一份合并报告(聚合指标 + 各任务明细)(2026-08-20) |
| 批量执行跳转监控页 + 3 个 P0 修复 | ✅ 已部署 | BatchMonitor.vue + 500/残留completed/status_2xx 修复(2026-08-21) | | 批量执行跳转监控页 + 3 个 P0 修复 | ✅ 已部署 | BatchMonitor.vue + 500/残留completed/status_2xx 修复(2026-08-21) |
| body 字符串存储导致 4XX 修复 | ✅ 已部署 | 前端 JSON.parse + 后端防御性 json.loads + 数据库脏数据清洗(2026-08-21) | | body 字符串存储导致 4XX 修复 | ✅ 已部署 | 前端 JSON.parse + 后端防御性 json.loads + 数据库脏数据清洗(2026-08-21) |
| 事务处理时间(Transaction) | ✅ 本地完成 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表(2026-08-21) | | 事务处理时间(Transaction) | ✅ 已部署 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表(2026-08-21) |
| 目标机资源监控(SSH)+ Java 进程监控 | ✅ 已部署 | SSH 采集 CPU/内存/负载 + Java 进程级监控 + 实时监控/报告展示(2026-08-22) | | 目标机资源监控(SSH)+ Java 进程监控 | ✅ 已部署 | SSH 采集 CPU/内存/负载 + Java 进程级监控 + 实时监控/报告展示(2026-08-22) |
| **两种压测场景增强(burst/endurance)+ 报告页场景标签/多接口明细表** | **✅ 本地完成,未部署** | 瞬时并发(集合点同步/循环模式/错误策略)+ 8 小时长稳压测(多接口线程组/思考时间分布/CSV 参数化)(2026-08-22) | | **两种压测场景增强(burst/endurance)+ 报告页场景标签/多接口明细表** | **✅ 已部署,5.60 e2e 验证通过** | 瞬时并发(集合点同步/循环模式/错误策略)+ 8 小时长稳压测(多接口线程组/思考时间分布/CSV 参数化)(2026-08-22) |
--- ---
## 🆕 最新会话进度(2026-08-22 — 两种压测场景增强 burst/endurance + 报告页场景标签/多接口明细表) ## 🆕 当前会话(2026-08-24 — WebSocket 批量进度订阅端点 + TaskList 中文回归)
**任务来源**:P1 遗留「WebSocket 批量进度订阅端点」→ 项目详情页批量执行实时进度条闭环。
### 已完成(本地,待 commit/部署)
| 功能 | 状态 | 说明 |
|------|------|------|
| 后端 `/ws/batch/{batch_id}` 端点 | ✅ 本地完成 | 订阅 `perf_batch_{batch_id}` 组;连接时从 DB 推送当前批次状态快照;ping/pong 保活 |
| 批量进度广播重构 | ✅ 本地完成 | `_broadcast_progress()` 同时推送全局 `perf_batch` + 批次专属 `perf_batch_{batch_id}` 两组 |
| 前端 `createPerfBatchStream()` | ✅ 本地完成 | api/performance.ts 新增批次专属 WS 连接封装 |
| ProjectDetail.vue 进度条闭环 | ✅ 本地完成 | 批量/全部执行后连接批次 WS,实时刷新进度/成功失败数,完成自动刷任务列表,关闭按钮断开 |
| `PerfBatchProgress.currentStatus` 类型修复 | ✅ 本地完成 | 补 `partially_failed` / `cancelled` 联合类型,修复 TS2367 编译错误 |
| TaskList.vue 文案中文回归 | ✅ 本地完成 | 全部标签从「英文+中文备注」改回纯中文;仅保留性能指标列英文+中文(TPS(吞吐量)/ Error Rate(错误率)) |
### 部署 5.60
**已部署(2026-08-22)**:burst/endurance 场景增强 + 报告页场景标签/接口明细表 + Transaction + 目标机监控 + Java 进程监控。
**本次会话改动尚未部署 5.60**(WS 批量进度端点 + TaskList 中文回归),待下次部署。
---
## 最新会话进度(2026-08-22 — 部署到 5.60 + e2e 验证 ✅)
> **部署包**:后端 9 文件 + 前端 dist(93 文件)→ 5.60 服务器
> **修复**:api_summary dict→List[Dict] 格式不匹配 → endurance 报告页正常显示 apiSummary 明细表
> **e2e 验证**:burst/endurance 场景标签 + apiSummary 表格 + 旧任务无回归 + 全部 API 端点 200 ✅
### 已部署功能清单(5.60 全部上线)
| 功能 | 状态 | 说明 |
|------|------|------|
| Transaction 事务处理 | ✅ 已部署 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表 |
| Burst 场景增强 | ✅ 已部署 | 集合点同步 + 循环模式(finite/infinite) + 错误策略(continue/stop) |
| Endurance 场景增强 | ✅ 已部署 | 多接口独立线程组 + 思考时间分布 + CSV 参数化 |
| 报告页场景标签 | ✅ 已验证 | burst 橙色 / endurance 红色 / legacy 灰色 |
| 报告页多接口明细表 | ✅ 已验证 | apiSummary 表格:接口名/请求数/成功/失败/错误率/TPS/响应时间分位/标准差 |
**phase 1 后端模型+Schema+迁移**`PerformanceTask` 新增 `scenario_type`(null/burst/endurance)+ 配套字段(loop_type/loop_count/synchronizing_timer_count/synchronizing_timer_timeout/error_action/scenario_apis/think_time_min/think_time_max/think_time_distribution/csv_content)。Pydantic camelCase 别名 + `populate_by_name` **phase 1 后端模型+Schema+迁移**`PerformanceTask` 新增 `scenario_type`(null/burst/endurance)+ 配套字段(loop_type/loop_count/synchronizing_timer_count/synchronizing_timer_timeout/error_action/scenario_apis/think_time_min/think_time_max/think_time_distribution/csv_content)。Pydantic camelCase 别名 + `populate_by_name`
...@@ -42,7 +78,14 @@ ...@@ -42,7 +78,14 @@
**验证**:schema 冒烟 3 种 payload ✅ / `PerformanceReportResponse` 构造 ✅ / pytest 25 passed ✅ / npm build ~26s ✅ **验证**:schema 冒烟 3 种 payload ✅ / `PerformanceReportResponse` 构造 ✅ / pytest 25 passed ✅ / npm build ~26s ✅
**⚠️ 未部署 5.60**,见 P0 部署任务。 **✅ 已部署 5.60(2026-08-22 16:00)**:后端 9 文件(models/schemas/database/executors/routers/services + 目标机 monitor)+ `_ensure_columns` 自动迁移 + performance_tasks 手动补 `csv_content`/`csv_variable_mapping` 两列(MySQL TEXT 不支持 DEFAULT)→ 前端 dist 全量覆盖 → 容器重启 healthy。
**e2e 验证全通过**
- burst 任务(perf_97fe...):执行 completed,15 req / 2.88 tps / 0% 错误率 → 报告页橙色标签「瞬时并发 Burst」✅
- endurance 任务(perf_3b50...):执行 completed,107 req / 1.83 tps / 2 接口 → 报告页红色标签「长稳压测 Endurance」+ apiSummary 明细表 2 行(会议列表 60 req / 模板列表 47 req)✅
- legacy 旧任务:灰色标签「标准 Standard」,apiSummary 为空不显示表格(无回归)✅
- **api_summary 格式修复**:执行器 `_build_api_summary()` 由 dict 改为 `List[Dict]`(camelCase 匹配前端 `ApiSummaryData` 接口),`PerformanceTaskResponse.api_summary` 同步 `Dict``List[Dict]`,修复 endurance 报告 API 500
- 冒烟测试全部 API 端点 200 ✅
--- ---
...@@ -63,7 +106,7 @@ ...@@ -63,7 +106,7 @@
| 08-17 | 会议预约 API 端到端验证 + A0027 修复(stale pyc 坑) | ✅ 已部署 | | 08-17 | 会议预约 API 端到端验证 + A0027 修复(stale pyc 坑) | ✅ 已部署 |
| 08-19 | 读取系统配置 URL + 自定义登录凭据 + curl 导入 + 修复 curl [object Object] + 修复保存 422 | ✅ 已部署 | | 08-19 | 读取系统配置 URL + 自定义登录凭据 + curl 导入 + 修复 curl [object Object] + 修复保存 422 | ✅ 已部署 |
| 08-20 | 项目管理与批量执行 + 合并报告 + 指标体系增强(10 项新指标,25 单测) | ✅ 已部署 | | 08-20 | 项目管理与批量执行 + 合并报告 + 指标体系增强(10 项新指标,25 单测) | ✅ 已部署 |
| 08-21 | 资源图表宽度修复 + 目标机监控 PRD + Transaction 前后端(瀑布图/步骤明细表) | ⚠️ Transaction 未部署 | | 08-21 | 资源图表宽度修复 + 目标机监控 PRD + Transaction 前后端(瀑布图/步骤明细表) | ✅ 已部署(08-22) |
| 08-21 | 批量执行跳转监控页(BatchMonitor)+ 3 个 P0 修复(500/残留completed/status_2xx)+ body 4XX 修复 | ✅ 已部署 | | 08-21 | 批量执行跳转监控页(BatchMonitor)+ 3 个 P0 修复(500/残留completed/status_2xx)+ body 4XX 修复 | ✅ 已部署 |
--- ---
...@@ -72,12 +115,12 @@ ...@@ -72,12 +115,12 @@
### 生产服务器 192.168.5.60 ### 生产服务器 192.168.5.60
- 容器 `plat-auto-test-app`:healthy,内部端口 80 - 容器 `plat-auto-test-app`:healthy,内部端口 80
- **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控 - **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控 + Transaction 事务处理 + burst/endurance 场景增强 + 报告页场景标签/多接口明细表
- **未部署**Transaction 事务处理时间(前后端)、burst/endurance 场景增强(前后端 - **未部署**无(所有功能已部署
### 本地(Windows 开发机) ### 本地(Windows 开发机)
- 后端 8001 / 前端 3000 | 数据库 `data/test_platform.db` - 后端 8001 / 前端 3000 | 数据库 `data/test_platform.db`
- **最新改动**burst/endurance 场景增强 + 报告页场景标签/接口明细表(后端 5 文件 + 前端 3 文件,本地 build+单测通过,未部署) - **最新改动**WebSocket 批量进度订阅端点(后端 2 文件)+ ProjectDetail 实时进度条 + TaskList 中文回归(前端 4 文件,本地 build 通过,未部署)
--- ---
...@@ -117,17 +160,8 @@ Docs/PRD/性能测试/问题处理/ # 各问题 ...@@ -117,17 +160,8 @@ Docs/PRD/性能测试/问题处理/ # 各问题
## 下一步任务 ## 下一步任务
### P0 — 部署场景增强(burst/endurance)到 5.60 + e2e 验证 ### P1 — 待办
1. 部署后端 5 文件:`models/performance.py` / `schemas/performance.py` / `database.py` / `executors/performance_executor.py` / `routers/performance.py` - ✅ WebSocket 批量进度订阅端点 **已完成(2026-08-24 本地)**,待部署 5.60
2. 前端 `npm run build` → dist 全量 scp
3.`*.pyc` + `docker compose restart app` → healthy(验证 `_ensure_columns` 自动迁移)
4. e2e:burst 任务→橙色标签 / endurance 任务→红色标签+apiSummary 表 / legacy 任务→标准 Standard
### P0 — 部署 Transaction 到 5.60 + e2e 验证
后端 3 文件(models/schemas/executors)+ 前端 build → 验证瀑布图+步骤明细表
### P1 — WebSocket 批量进度订阅端点
后端缺 `/ws/batch/{batch_id}` 订阅端点 → ProjectDetail 实时进度条收不到推送
### P1 — 按请求序号错开时间段(并发预约类接口关键) ### P1 — 按请求序号错开时间段(并发预约类接口关键)
`template_resolver.py` 增加 `INDEX` 后缀支持,如 `{__NOW_+37_H_INDEX__}` `template_resolver.py` 增加 `INDEX` 后缀支持,如 `{__NOW_+37_H_INDEX__}`
......
...@@ -306,18 +306,20 @@ class MetricsCollector: ...@@ -306,18 +306,20 @@ class MetricsCollector:
"error_type_assertion": self._error_counts.get("assertion_fail", 0), "error_type_assertion": self._error_counts.get("assertion_fail", 0),
} }
def _build_api_summary(self) -> Optional[dict]: def _build_api_summary(self) -> Optional[list]:
""" """
构建接口维度指标汇总(长稳压测多接口场景,执行结束后调用) 构建接口维度指标汇总(长稳压测多接口场景,执行结束后调用)
Returns: Returns:
dict: {api_name: {count, avgRt, minRt, maxRt, p50, p90, p95, p99, list: [{name, totalRequests, successCount, failCount, avgResponseTime,
tps, errorRate, status2xx, status3xx, status4xx, status5xx}} minResponseTime, maxResponseTime, p50ResponseTime, p90ResponseTime,
p95ResponseTime, p99ResponseTime, stdDev, errorRate, actualTps,
status2xx, status3xx, status4xx, status5xx, sentBytes, receivedBytes}]
无接口数据返回 None 无接口数据返回 None
""" """
if not self._api_stats: if not self._api_stats:
return None return None
result = {} result = []
elapsed = max(time.time() - self._start_time, 1) elapsed = max(time.time() - self._start_time, 1)
for api_name, stats in self._api_stats.items(): for api_name, stats in self._api_stats.items():
times = stats["times"] times = stats["times"]
...@@ -329,26 +331,31 @@ class MetricsCollector: ...@@ -329,26 +331,31 @@ class MetricsCollector:
p90 = sorted_t[int(n * 0.9)] if n > 1 else sorted_t[0] p90 = sorted_t[int(n * 0.9)] if n > 1 else sorted_t[0]
p95 = sorted_t[int(n * 0.95)] if n > 1 else sorted_t[0] p95 = sorted_t[int(n * 0.95)] if n > 1 else sorted_t[0]
p99 = sorted_t[int(n * 0.99)] if n > 1 else sorted_t[0] p99 = sorted_t[int(n * 0.99)] if n > 1 else sorted_t[0]
result[api_name] = { mean = sum(times) / n
"count": stats["count"], std_dev = math.sqrt(sum((x - mean) ** 2 for x in times) / n) if n > 1 else 0
fail_count = stats["count"] - stats["success"]
result.append({
"name": api_name,
"totalRequests": stats["count"],
"successCount": stats["success"], "successCount": stats["success"],
"failCount": stats["count"] - stats["success"], "failCount": fail_count,
"errorRate": round((stats["count"] - stats["success"]) / max(stats["count"], 1) * 100, 2), "avgResponseTime": round(mean, 2),
"avgResponseTime": round(sum(times) / n, 2) if n else 0,
"minResponseTime": round(min(times), 2) if n else 0, "minResponseTime": round(min(times), 2) if n else 0,
"maxResponseTime": round(max(times), 2) if n else 0, "maxResponseTime": round(max(times), 2) if n else 0,
"p50": round(p50, 2), "p50ResponseTime": round(p50, 2),
"p90": round(p90, 2), "p90ResponseTime": round(p90, 2),
"p95": round(p95, 2), "p95ResponseTime": round(p95, 2),
"p99": round(p99, 2), "p99ResponseTime": round(p99, 2),
"tps": round(stats["success"] / elapsed, 2), "stdDev": round(std_dev, 2),
"errorRate": round(fail_count / max(stats["count"], 1), 4),
"actualTps": round(stats["success"] / elapsed, 2),
"status2xx": stats["status_2xx"], "status2xx": stats["status_2xx"],
"status3xx": stats["status_3xx"], "status3xx": stats["status_3xx"],
"status4xx": stats["status_4xx"], "status4xx": stats["status_4xx"],
"status5xx": stats["status_5xx"], "status5xx": stats["status_5xx"],
"sentBytes": stats["sent_bytes"], "sentBytes": stats["sent_bytes"],
"receivedBytes": stats["received_bytes"], "receivedBytes": stats["received_bytes"],
} })
return result return result
@staticmethod @staticmethod
......
...@@ -344,6 +344,75 @@ async def websocket_batch_progress(websocket: WebSocket): ...@@ -344,6 +344,75 @@ async def websocket_batch_progress(websocket: WebSocket):
await manager.disconnect(websocket, ws_group) await manager.disconnect(websocket, ws_group)
@router.websocket("/ws/batch/{batch_id}")
async def websocket_batch_progress_specific(websocket: WebSocket, batch_id: str):
"""
批量执行进度 WebSocket(按批次分组)
客户端连接后订阅 perf_batch_{batch_id} 分组,只收到该批次的进度广播,
避免多批次并发时全局分组串扰。
连接建立后立即推送当前批次状态快照(若记录已存在),
防止客户端错过连接前已完成的进度。
消息格式(perf_batch_progress):
```json
{
"type": "perf_batch_progress",
"batch_id": "...",
"project_id": "...",
"current_index": 1,
"total_count": 5,
"current_task_id": "...",
"current_task_name": "任务名",
"current_status": "running",
"success_count": 1,
"fail_count": 0
}
```
"""
ws_group = f"perf_batch_{batch_id}"
await manager.connect(websocket, ws_group)
# 连接建立后推送当前批次状态快照(若记录已存在)
try:
async with async_session_maker() as db:
service = PerformanceService(db)
details = await service.get_batch_details(batch_id)
if details:
done_count = (details.get("success_count") or 0) + (details.get("fail_count") or 0)
await manager.send_to(websocket, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": details.get("project_id"),
"current_index": done_count,
"total_count": details.get("total_count") or 0,
"current_task_id": None,
"current_task_name": None,
"current_status": details.get("status") or "running",
"success_count": details.get("success_count") or 0,
"fail_count": details.get("fail_count") or 0,
})
except Exception as e:
logger.warning(f"推送批量进度快照失败: {e}")
try:
# 保持连接,等待推送
while True:
try:
data = await websocket.receive_text()
msg = json.loads(data)
if msg.get("type") == "ping":
await manager.send_to(websocket, {"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
logger.info(f"批量进度 WebSocket 断开连接: batch_id={batch_id}")
except Exception as e:
logger.error(f"批量进度 WebSocket 异常: {e}")
finally:
await manager.disconnect(websocket, ws_group)
@router.websocket("/ws/{task_id}") @router.websocket("/ws/{task_id}")
async def websocket_monitor(websocket: WebSocket, task_id: str): async def websocket_monitor(websocket: WebSocket, task_id: str):
""" """
......
...@@ -273,8 +273,8 @@ class PerformanceTaskResponse(BaseModel): ...@@ -273,8 +273,8 @@ class PerformanceTaskResponse(BaseModel):
think_time_max: int = 3000 think_time_max: int = 3000
think_time_distribution: str = "constant" think_time_distribution: str = "constant"
csv_parameterization_enabled: bool = False csv_parameterization_enabled: bool = False
csv_content: str = "" csv_content: Optional[str] = None
csv_variable_mapping: Dict[str, str] = Field(default_factory=dict) csv_variable_mapping: Optional[Dict[str, str]] = None
# 资源监控汇总 # 资源监控汇总
resource_summary: Optional[Dict[str, Any]] = None resource_summary: Optional[Dict[str, Any]] = None
...@@ -283,7 +283,7 @@ class PerformanceTaskResponse(BaseModel): ...@@ -283,7 +283,7 @@ class PerformanceTaskResponse(BaseModel):
# 事务处理汇总 # 事务处理汇总
transaction_summary: Optional[Dict[str, Any]] = None transaction_summary: Optional[Dict[str, Any]] = None
# 接口维度指标汇总 # 接口维度指标汇总
api_summary: Optional[Dict[str, Any]] = None api_summary: Optional[List[Dict[str, Any]]] = None
# 结果统计 # 结果统计
total_requests: int = 0 total_requests: int = 0
......
...@@ -1353,10 +1353,20 @@ class PerformanceService: ...@@ -1353,10 +1353,20 @@ class PerformanceService:
fail_count = 0 fail_count = 0
results = [] results = []
# 广播分组:全局分组(BatchMonitor 兼容)+ 批次专属分组(/ws/batch/{batch_id})
ws_group = "perf_batch" ws_group = "perf_batch"
batch_group = f"perf_batch_{batch_id}"
async def _broadcast_progress(payload: Dict[str, Any]) -> None:
"""同时广播到全局分组与批次专属分组"""
for group in (ws_group, batch_group):
try:
await manager.broadcast(group, payload)
except Exception as e:
logger.warning(f"批量进度广播失败 [{group}]: {e}")
# 广播批量执行开始 # 广播批量执行开始
await manager.broadcast(ws_group, { await _broadcast_progress({
"type": "perf_batch_progress", "type": "perf_batch_progress",
"batch_id": batch_id, "batch_id": batch_id,
"project_id": project_id, "project_id": project_id,
...@@ -1378,7 +1388,7 @@ class PerformanceService: ...@@ -1378,7 +1388,7 @@ class PerformanceService:
task_name = task.name if task else task_id task_name = task.name if task else task_id
# 广播当前任务开始 # 广播当前任务开始
await manager.broadcast(ws_group, { await _broadcast_progress({
"type": "perf_batch_progress", "type": "perf_batch_progress",
"batch_id": batch_id, "batch_id": batch_id,
"project_id": project_id, "project_id": project_id,
...@@ -1414,7 +1424,7 @@ class PerformanceService: ...@@ -1414,7 +1424,7 @@ class PerformanceService:
}) })
# 广播当前任务完成 # 广播当前任务完成
await manager.broadcast(ws_group, { await _broadcast_progress({
"type": "perf_batch_progress", "type": "perf_batch_progress",
"batch_id": batch_id, "batch_id": batch_id,
"project_id": project_id, "project_id": project_id,
...@@ -1429,7 +1439,7 @@ class PerformanceService: ...@@ -1429,7 +1439,7 @@ class PerformanceService:
# 广播批量执行完成 # 广播批量执行完成
final_status = "completed" if fail_count == 0 else "partially_failed" final_status = "completed" if fail_count == 0 else "partially_failed"
await manager.broadcast(ws_group, { await _broadcast_progress({
"type": "perf_batch_progress", "type": "perf_batch_progress",
"batch_id": batch_id, "batch_id": batch_id,
"project_id": project_id, "project_id": project_id,
......
{
"name": "burst场景-会议列表查询",
"targetUrl": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"method": "GET",
"concurrency": 5,
"duration": 30,
"scenarioType": "burst",
"loopType": "finite",
"loopCount": 3,
"synchronizingTimerEnabled": true,
"synchronizingTimerCount": 5,
"synchronizingTimerTimeout": 30000,
"errorAction": "continue",
"authRequired": true,
"accountKey": "superadmin",
"signRequest": false
}
\ No newline at end of file
{
"name": "endurance场景-多接口长稳",
"targetUrl": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"method": "GET",
"concurrency": 3,
"duration": 30,
"scenarioType": "endurance",
"scenarioApis": [
{
"name": "会议列表查询",
"method": "GET",
"url": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"threads": 2,
"headers": {},
"body": null
},
{
"name": "模板列表查询",
"method": "GET",
"url": "https://192.168.5.44/meetingV3/api/template/getTemplatePage?pageNum=1&pageSize=10&companyNumber=CN-WQF-UBAINS&keyword=",
"threads": 1,
"headers": {},
"body": null
}
],
"thinkTimeEnabled": true,
"thinkTimeMin": 500,
"thinkTimeMax": 1000,
"thinkTimeDistribution": "constant",
"authRequired": true,
"accountKey": "superadmin",
"signRequest": false
}
\ No newline at end of file
...@@ -140,6 +140,40 @@ export function createPerfMonitorStream( ...@@ -140,6 +140,40 @@ export function createPerfMonitorStream(
return ws return ws
} }
/** 创建 WebSocket 连接订阅批量执行进度(按批次分组) */
export function createPerfBatchStream(
batchId: string,
onProgress: (data: any) => void,
onError?: (error: Event) => void,
onClose?: () => void,
): WebSocket {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const ws = new WebSocket(`${protocol}//${host}/api/performance/ws/batch/${batchId}`)
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'perf_batch_progress') {
onProgress(msg)
}
} catch (e) {
console.error('解析批量进度 WebSocket 消息失败', e)
}
}
ws.onerror = (error) => {
console.error('批量进度 WebSocket 错误', error)
onError?.(error)
}
ws.onclose = () => {
onClose?.()
}
return ws
}
// ==================== API 接口预设 ==================== // ==================== API 接口预设 ====================
/** 获取预设列表 */ /** 获取预设列表 */
......
<!--
组件名称:FieldTip.vue
组件描述:表单参数帮助提示图标(?悬浮查看参数说明)
@author czj
@date 2026-08-22
-->
<template>
<el-tooltip :content="content" placement="top">
<el-icon style="margin-left: 4px; cursor: help; color: #909399; vertical-align: middle;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</template>
<script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue'
defineProps<{
/** 悬浮提示文案 */
content: string
}>()
</script>
\ No newline at end of file
...@@ -805,7 +805,7 @@ export interface PerfBatchProgress { ...@@ -805,7 +805,7 @@ export interface PerfBatchProgress {
totalCount: number totalCount: number
currentTaskId: string currentTaskId: string
currentTaskName: string currentTaskName: string
currentStatus: 'running' | 'completed' | 'failed' currentStatus: 'running' | 'completed' | 'failed' | 'partially_failed' | 'cancelled'
successCount: number successCount: number
failCount: number failCount: number
} }
......
...@@ -43,6 +43,49 @@ ...@@ -43,6 +43,49 @@
{{ project.description }} {{ project.description }}
</div> </div>
<!-- 批量执行实时进度卡片 -->
<el-card v-if="batchProgress" shadow="never" class="batch-progress-card">
<div class="batch-progress-header">
<span>
<el-icon style="vertical-align: -2px; margin-right: 4px"><Loading v-if="isBatchRunning" /></el-icon>
Batch Progress(批量执行进度)
</span>
<el-tag :type="batchStatusType(batchProgress.currentStatus)" size="small">
{{ batchStatusLabel(batchProgress.currentStatus) }}
</el-tag>
</div>
<div class="batch-progress-body">
<el-progress
:percentage="batchPercent"
:status="batchProgress.currentStatus === 'running' ? undefined : (batchProgress.failCount > 0 ? 'exception' : 'success')"
:stroke-width="14"
:text-inside="true"
/>
<div class="batch-progress-info">
<span class="batch-progress-count">
{{ batchProgress.successCount }} Success(成功) / {{ batchProgress.failCount }} Failed(失败)
</span>
<span>
{{ batchProgress.currentIndex }} / {{ batchProgress.totalCount }}{{ batchPercent }}%)
</span>
</div>
<div v-if="batchProgress.currentTaskName" class="batch-progress-text">
Current Task(当前任务): {{ batchProgress.currentTaskName }}
<el-button link type="primary" size="small" style="margin-left: 12px" @click="disconnectBatchWs">
关闭进度
</el-button>
</div>
<div v-else class="batch-progress-text">
<el-button v-if="!isBatchRunning && batchProgress.totalCount > 0" link type="primary" size="small" @click="viewBatchMonitor">
查看批次明细
</el-button>
<el-button link type="primary" size="small" style="margin-left: 12px" @click="disconnectBatchWs">
关闭进度
</el-button>
</div>
</div>
</el-card>
<!-- 任务列表 --> <!-- 任务列表 -->
<el-card shadow="never"> <el-card shadow="never">
<div class="toolbar"> <div class="toolbar">
...@@ -158,12 +201,12 @@ ...@@ -158,12 +201,12 @@
* 6. 单任务执行/停止/删除/跳转监控报告 * 6. 单任务执行/停止/删除/跳转监控报告
*/ */
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, VideoPlay, Refresh, Document, List } from '@element-plus/icons-vue' import { ArrowLeft, VideoPlay, Refresh, Document, List, Loading } from '@element-plus/icons-vue'
import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks } from '@/api/performance' import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks, createPerfBatchStream } from '@/api/performance'
import type { PerformanceProject, PerformanceTaskListItem } from '@/types/performance' import type { PerformanceProject, PerformanceTaskListItem, PerfBatchProgress } from '@/types/performance'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
...@@ -188,6 +231,15 @@ const tableRef = ref() ...@@ -188,6 +231,15 @@ const tableRef = ref()
const lastReportId = ref<string | null>(null) // 最近一次批量执行生成的合并报告 ID const lastReportId = ref<string | null>(null) // 最近一次批量执行生成的合并报告 ID
// 批量执行进度
const batchProgress = ref<PerfBatchProgress | null>(null)
const batchWs = ref<WebSocket | null>(null)
const isBatchRunning = computed(() => batchProgress.value?.currentStatus === 'running')
const batchPercent = computed(() => {
if (!batchProgress.value || batchProgress.value.totalCount === 0) return 0
return Math.round((batchProgress.value.currentIndex / batchProgress.value.totalCount) * 100)
})
// ==================== 方法定义 ==================== // ==================== 方法定义 ====================
/** 返回项目管理页 */ /** 返回项目管理页 */
...@@ -275,11 +327,21 @@ async function handleRunAll() { ...@@ -275,11 +327,21 @@ async function handleRunAll() {
runningAll.value = true runningAll.value = true
try { try {
const res = await runProjectAll(projectId.value) const res = await runProjectAll(projectId.value)
// 立即跳转批量监控页 // 连接批次专属 WebSocket 订阅进度
router.push({ connectBatchWs(res.batchId)
path: '/performance/batch-monitor', // 初始化进度
query: { batchId: res.batchId, projectId: projectId.value }, batchProgress.value = {
}) type: 'perf_batch_progress',
batchId: res.batchId,
projectId: projectId.value,
currentIndex: 0,
totalCount: res.taskCount || res.taskIds?.length || 0,
currentTaskId: '',
currentTaskName: '',
currentStatus: 'running',
successCount: 0,
failCount: 0,
}
} catch (e: any) { } catch (e: any) {
ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || '')) ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
runningAll.value = false runningAll.value = false
...@@ -303,11 +365,21 @@ async function handleBatchRun() { ...@@ -303,11 +365,21 @@ async function handleBatchRun() {
runningBatch.value = true runningBatch.value = true
try { try {
const res = await batchRunTasks({ taskIds: selectedIds.value, projectId: projectId.value }) const res = await batchRunTasks({ taskIds: selectedIds.value, projectId: projectId.value })
// 立即跳转批量监控页 // 连接批次专属 WebSocket 订阅进度
router.push({ connectBatchWs(res.batchId)
path: '/performance/batch-monitor', // 初始化进度
query: { batchId: res.batchId, projectId: projectId.value }, batchProgress.value = {
}) type: 'perf_batch_progress',
batchId: res.batchId,
projectId: projectId.value,
currentIndex: 0,
totalCount: res.taskCount || selectedIds.value.length,
currentTaskId: '',
currentTaskName: '',
currentStatus: 'running',
successCount: 0,
failCount: 0,
}
} catch (e: any) { } catch (e: any) {
ElMessage.error('批量执行失败: ' + (e.response?.data?.detail || e.message || '')) ElMessage.error('批量执行失败: ' + (e.response?.data?.detail || e.message || ''))
runningBatch.value = false runningBatch.value = false
...@@ -342,6 +414,76 @@ function viewHistoryReports() { ...@@ -342,6 +414,76 @@ function viewHistoryReports() {
router.push(`/performance/project-report?projectId=${projectId.value}`) router.push(`/performance/project-report?projectId=${projectId.value}`)
} }
/** 跳转批量监控页 */
function viewBatchMonitor() {
if (batchProgress.value?.batchId) {
router.push({
path: '/performance/batch-monitor',
query: { batchId: batchProgress.value.batchId, projectId: projectId.value },
})
}
}
// ==================== 批量进度 WebSocket ====================
/** 连接批次专属 WebSocket 订阅批量执行进度 */
function connectBatchWs(batchId: string) {
disconnectBatchWs()
batchWs.value = createPerfBatchStream(
batchId,
handleBatchProgress,
() => { /* 连接错误,进度条停在上次状态 */ },
() => { batchWs.value = null },
)
}
/** 断开批量进度 WebSocket */
function disconnectBatchWs() {
if (batchWs.value) {
batchWs.value.close()
batchWs.value = null
}
}
/** 处理批量进度消息 */
function handleBatchProgress(msg: PerfBatchProgress) {
batchProgress.value = msg
// 批量执行完成时刷新任务列表(状态可能有变化)
if (msg.currentStatus === 'completed' || msg.currentStatus === 'partially_failed') {
loadTasks()
loadProject()
}
// 开始执行后可以停止按钮 loading
if (msg.currentIndex > 0) {
runningAll.value = false
runningBatch.value = false
}
}
/** 批量状态标签类型 */
function batchStatusType(status: string) {
const map: Record<string, string> = {
running: 'primary',
completed: 'success',
partially_failed: 'warning',
failed: 'danger',
cancelled: 'info',
}
return map[status] || 'info'
}
/** 批量状态标签文字 */
function batchStatusLabel(status: string) {
const map: Record<string, string> = {
running: 'Running(执行中)',
completed: 'Completed(已完成)',
partially_failed: 'Partially Failed(部分失败)',
failed: 'Failed(失败)',
cancelled: 'Cancelled(已取消)',
}
return map[status] || status || 'Running(执行中)'
}
/** 格式化时间 */ /** 格式化时间 */
function formatTime(t: string) { function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || '' return t?.replace('T', ' ').substring(0, 19) || ''
...@@ -352,6 +494,10 @@ onMounted(() => { ...@@ -352,6 +494,10 @@ onMounted(() => {
loadProject() loadProject()
loadTasks() loadTasks()
}) })
onUnmounted(() => {
disconnectBatchWs()
})
</script> </script>
<style scoped> <style scoped>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论