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

fix(performance): 混合场景CSV强制校验与登录监控记录+目标机监控开启与监控图表X轴修复

混合场景执行安全:
- performance_service 新增 _validate_run_preconditions:mix 未启用 CSV / CSV 空 /
  账号数<并发数 → HTTP 400(run_task 与 run_task_async 双入口)
- performance_executor _run_mix:CSV 校验提升到 per_vu_login 之外(兜底封死直调路径),
  列映射缺 username/password 时 warning,per-VU 缺列拒绝
- 登录三路计入 MetricsCollector(共享预登录 / per-VU 启动登录 / 随机池重登,
  api_summary 归「登录」),登录 QPS/成功率可见
- TaskList.vue handleRun mix 无 CSV 前置拦截;schema + types 新增
  csvParameterizationEnabled/csvContent/perVuLogin 字段
- CSV 模板改为前端 XLSX 动态生成(不再依赖部署静态文件)

目标机监控开启:
- docker-compose 设置 TARGET_MONITOR_ENABLED=true(根因:5.60 容器默认关闭,
  混合场景全程无目标机资源数据)

监控图表 X 轴修复:
- MonitorPanel 折叠面板零尺寸画布 init 后 resize 兜底(X 轴被压成窄条根因)
- 资源/网络/目标机图表增加 dataZoom(inside + slider),保留用户缩放窗口
- 运行中连续 10s 无目标机数据自动展开空态面板展示原因
- 窗口 resize 时全部图表自适应
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 d2660651
# HANDOFF — 性能测试模块会话交接文档
> **生成时间**: 2026-08-31
> **生成时间**: 2026-09-02
> **当前分支**: `platform-auto-test`
> **最近提交**: `ad4163bb` feat(perf): 目标机 Java 进程瞬时 CPU 采集 + 多实例服务级聚合与 MySQL 趋势(已同步 origin/platform-auto-test)
> **会话窗口**: 性能测试 — 修复 Java 进程 CPU 监控失真(ps 生命周期平均 → JSTAT 瞬时采样,实测与 top 的 300%+ 一致)+ 批前功能提交推送
> **状态**: ✅ Batch A(5 关键字 + 多 PID 聚合 + MySQL 趋势)与 Batch B(JSTAT 瞬时 CPU)均已实现、验证、部署 5.60,并合并提交 `ad4163bb` 推送 origin/platform-auto-test
> **会话窗口(上一窗口)**: 清理提交(HANDOFF 文档入库 + Vue placeholder 转义修复)`11e1dd85`
> **最近提交**: `03ee164c` docs(performance): HANDOFF 补充 5.60 部署 md5 复核与双 commit 记录(已同步 origin/platform-auto-test)
> **会话窗口**: 性能测试 — 混合场景(mix)执行失败修复:CSV 强制校验(无 CSV 不允许执行)+ 登录计入 api_summary 监控,已部署 5.60 并端到端验证通过
> **状态**: ✅ 修复已实现、部署 5.60、E2E 验收通过(A/B 拒绝 + C 实跑含「登录」行);**改动未提交**(见待办)
> **会话窗口(上一窗口)**: 修复 Java 进程 CPU 监控失真(ps 生命周期平均 → JSTAT 瞬时采样)`ad4163bb`
---
## ⚡ 最新会话更新(2026-09-02)— 混合场景执行失败修复:CSV 强制校验 + 登录计入监控 ✅
### A. 会话背景
用户反馈:**混合场景(mix)任务导入 100 个 CSV 账号后「一执行就立即失败」**
错误信息 `登录失败,无法获取 Token (account: superadmin)`,无任何压测请求发出。
用户明确两条设计要求:
1. **没有导入 CSV 就不允许执行**
2. **登录步骤也要记录监控数据**(计入 api_summary)。
### B. 根因(4 层,详见问题处理文档)
| # | 层 | 根因 |
|---|----|------|
| 1 | 环境(直接原因) | 5.44 磁盘满 → Redis MISCONF → `GET /platform/api/code` 验证码 500 → 共享预登录失败 → 任务秒挂(用户已清理磁盘,验证码恢复 200) |
| 2 | 代码放大 | CSV 校验只在 `per_vu_login=True` 分支;该任务 `perVuLogin=false` 完全绕过校验,100 账号闲置 |
| 3 | 代码放大 | `csvVariableMapping={}` 静默回退默认账号,无任何告警 |
| 4 | 设计缺失 | 共享预登录 / per-VU 启动登录均不进 `MetricsCollector`,登录 QPS/成功率不可见 |
### C. 修复内容(3 层防线 + 登录监控)
| 层 | 文件 | 改动 |
|----|------|------|
| 服务层 | `backend/app/services/performance_service.py` | 新增 `_validate_run_preconditions()`:mix 未启用 CSV / CSV 空 / 账号数 < 并发数 → ValueError → HTTP 400;`run_task()``run_task_async()` 双入口调用 |
| 执行器 | `backend/app/executors/performance_executor.py` | `_run_mix()` CSV 校验提升到 `if per_vu_login:` 之外(兜底封死直调路径);映射缺 username/password 时 `logger.warning`;per-VU 缺列拒绝。登录三路计入监控:`execute()` 共享预登录、`_mix_worker()` per-VU 启动登录、`_send_request()` 随机池重登(`metrics_api_name="登录"`,请求明细池名保持原 api_name) |
| 前端 | `frontend/src/views/performance/TaskList.vue` | `handleRun()` mix 无 CSV 前置拦截(ElMessage.warning,不发请求);`PerformanceTaskListItem` schema + `types/performance.ts` 新增 `csvParameterizationEnabled` / `csvContent` / `perVuLogin` 三字段 |
| 文档 | `Docs/PRD/性能测试/问题处理/` | 新增 `_问题处理_混合场景无CSV即执行失败_登录监控缺失.md` + `_执行计划_修复混合场景CSV强制校验与登录监控记录.md` |
### D. 部署 5.60 ✅
1. 上传后端 3 文件(executor 141KB / service 104KB / schema 43KB)+ 前端 dist 94 文件;
2. `docker compose -f deploy/docker-compose.yml restart app``/health` 200;
3. 容器内 grep 5 个代码标记全部命中(`api_name="登录"` / `metrics_api_name` / 校验文案 / `_validate_run_preconditions` / `csv_parameterization_enabled`)。
### E. 端到端验证 ✅(5.60 实测,脚本 `backend/_e2e_mix_csv_560.py`)
| # | 场景 | 结果 |
|---|------|------|
| A | mix 未导入 CSV → `POST /tasks/{id}/run` | ✅ HTTP 400:`混合场景必须启用 CSV 参数化并导入账号数据,否则不允许执行` |
| B | CSV 仅 1 行账号、并发 100 | ✅ HTTP 400:`CSV 账号数(1)少于虚拟用户数(100),不允许执行` |
| C | 100 账号 + `perVuLogin=true` + 显式映射,30s 实跑 | ✅ `completed`;api_summary 4 行:**登录 100/100 全成功**、模板查询 58、模板创建 32、预定 42 —— 登录已计入监控 |
> 验证用克隆任务(`*_A_no_csv` / `*_B_few_accounts` / `*_C_pervu_30s`)残留在 5.60,可在页面上清理。
> 模板创建/预定失败率偏高属被测端数据/配置问题,不在本次修复范围。
### F. 本次会话待办(交接给下一窗口)
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | **提交本窗口改动** | P1 | backend 3 文件 + frontend 2 文件 + 2 份问题处理文档 + 本 HANDOFF 更新,均未提交(用户此前的提交授权仅覆盖模板表修复,本窗口改动待确认后用 /GitCommit 提交) |
| 2 | 清理 5.60 验证克隆任务 | P2 | A/B/C 三个 `*_A_no_csv` 等克隆任务 |
| 3 | 原任务复跑(可选) | P2 | `perf_1c72c087...`(用户 100 账号 mix 任务,failed 状态遗留)可按 perVuLogin=true + 映射配置重跑真实时长;注意会在 5.44 产生真实会议数据 |
---
......
# 问题处理:混合场景无 CSV 校验即执行失败 + 登录监控缺失
> 处理时间:2026-09-02
> 现象报告人:用户(混合场景导入 100 个用户后一执行就立即失败)
> 关联文档:`_PRD_性能测试.md`、`_执行计划_修复混合场景CSV强制校验与登录监控记录.md`
> 状态:✅ 根因已定位;修复方案已定(等 5.44 磁盘清理完成后部署复验)
---
## 1. 问题现象
混合场景任务 `perf_1c72c087ba4f4854b5f9a8c9a348f565` 已导入 100 个 CSV 账号,
**一执行就立即失败**,无任何压测请求发出。
| 配置项 | 值 |
|--------|-----|
| scenarioType | mix |
| perVuLogin | false |
| csvParameterizationEnabled | true |
| csvContent | 100 行(admin@xty ~ admin@xty99,表头 username,password) |
| csvVariableMapping | **{}(空)** |
| concurrency | 100 |
| status | failed |
| errorMessage | `登录失败,无法获取 Token (account: superadmin)` |
**预期**:用 100 个 CSV 账号各自登录、并发压测;
即便账号不足/配置缺失,也应给出**明确可操作的报错**,而不是秒挂。
---
## 2. 根因分析
### 2.1 直接原因(环境):5.44 服务器磁盘满 → Redis MISCONF → 验证码 500
执行链路:`execute()` 共享预登录 `_ensure_token()``HttpClient.login("superadmin")`
`get_captcha_uuid()` 请求 `GET /platform/api/code`**HTTP 500**
(验证码 uuid 获取失败)→ 登录请求因 uuid 缺失被拒 → 立即返回
`登录失败,无法获取 Token`
直接探测结果:
- `GET /platform/api/code` 连续 5 次 **HTTP 500**
- 随机 uuid 登录 → `MISCONF Redis is configured to save RDB snapshots...`(磁盘满,RDB 快照落盘失败);
- 模板查询接口返回 `401 令牌不能为空`(进程存活,仅存储/Redis 异常)。
环境根因非代码缺陷,用户正在清理 5.44 磁盘。
### 2.2 放大因素(代码):混合场景 CSV 校验不完整
`backend/app/executors/performance_executor.py:1731-1749`
```python
per_vu_login = getattr(task, "per_vu_login", False)
if per_vu_login:
if not csv_records or not csv_fields:
return {"error": True, "error_message": "per-VU 独立登录必须启用 CSV 参数化并导入账号数据"}
...
if len(csv_records) < task.concurrency:
return {"error": True,
"error_message": f"CSV 账号数({len(csv_records)})少于虚拟用户数({task.concurrency}),不允许执行"}
```
**CSV 校验只在 `per_vu_login=True` 时触发**。本次任务 `perVuLogin=false`
100 个导入账号完全闲置,走共享 superadmin 预登录 → 环境故障直接导致整体失败。
即:**功能设计允许 mix 不强制 CSV,与「导入账号压测」的用户意图不符。**
### 2.3 放大因素(代码):`csvVariableMapping={}` 静默回退默认账号
`_resolve_csv_credentials()` / `_get_login_body()`(performance_executor.py:768-806、
848-900):映射为空或列名不符时**静默**回退 `accounts.superadmin`
(admin@xty),无任何 warning。用户配置的 100 个账号若走 per-VU 也未必生效——
必须配置映射 `{"username":"{{csv.username}}","password":"{{csv.password}}"}`
### 2.4 设计缺失:登录不计入接口维度监控
用户指出「混合场景的测试没有把登录这个必要步骤记录下来」:
- 共享预登录:`execute()``_ensure_token()`(1129-1139),不在 `MetricsCollector` 中记录;
- per-VU 启动登录:`_mix_worker()``_ensure_vu_token()`(1841-1852),不在 `MetricsCollector` 中记录;
- 随机池重登:`_send_request()`(2091-2184)`is_login=True` 时已走 `record(api_name=...)`
但 api_name 用的是**随机池配置名**(如 "登录接口"),且未统一命名。
结果:`api_summary`(接口维度统计)**看不到登录**的 QPS/成功率/耗时,
瓶颈分析缺失关键一环。
---
## 3. 修复方案
### 3.1 没有 CSV 就不允许执行(双重拦截)
1. **服务层** `performance_service.py``run_task()` / `run_task_async()` 前置校验
`scenario_type == "mix"` 时:
- 未启用 `csv_parameterization_enabled` → 拒绝;
- `csv_content` 为空 / 无数据行 → 拒绝;
- 账号数(数据行数)< concurrency → 拒绝(明确报账号数 vs 并发数);
- 不满足时抛 `ValueError`(router 转 HTTP 400,前端弹「执行失败: ...」)。
2. **执行器** `performance_executor.py` `_run_mix()`:校验块从 `if per_vu_login:` 提到
外层,**无论是否 per-VU 都强制**,防止绕过服务层的调用(batch/脚本/API)放行。
3. **前端** `TaskList.vue` `handleRun()`:mix 未导入 CSV → `ElMessage.warning` 拦截。
### 3.2 登录步骤计入接口维度监控(api_summary「登录」)
- 共享预登录(`execute()`):`_ensure_token()` 结果以 `api_name="登录"` 记录;
- per-VU 启动登录(`_mix_worker()`):`_ensure_vu_token()` 结果以 `api_name="登录"` 记录
(含耗时、成功/失败);
- 随机池重登(`_send_request()`):`is_login=True` 时 api_name 统一传 `"登录"`
- 三条路径同一 api_name → `MetricsCollector` / `_build_api_summary()` **零改动**
即可在报告「接口维度统计」看到「登录」行:TPS/成功率/响应时间分位/错误数。
### 3.3 映射为空告警(不再静默回退)
`_run_mix()``csv_variable_mapping` 无 username/password 键时打印 warning,
提示配置字段映射(表头非字面 `username`/`password` 时必须配)。
详细步骤见 `_执行计划_修复混合场景CSV强制校验与登录监控记录.md`
---
## 4. 验证结果
| 验证项 | 结果 |
|--------|------|
| 5.44 `/platform/api/code` 恢复 200(环境前提) | ✅ 已验证 |
| mix 未导入 CSV → 前端拦截 / 后端 400 拒绝 | ⏳ 待部署 |
| mix + 100 账号 + 字段映射 → 正常压测 | ⏳ 待部署 |
| api_summary 出现「登录」行(TPS/成功率/耗时) | ⏳ 待部署 |
| 非 mix 场景回归(无 CSV 校验) | ⏳ 待部署 |
---
## 5. 经验总结
1. **环境故障与代码缺陷要分开定责**:本次秒挂的直接原因是 5.44 磁盘满
(Redis MISCONF → 验证码 500),代码缺陷是放大因素(不校验 CSV、误导性报错)。
只修代码不清理磁盘,问题依旧;只清理磁盘不改代码,下次账号配置错误还会秒挂。
2. **配置缺失要报错或告警,不要静默回退**`csvVariableMapping` 为空静默回退默认账号,
是 100 账号「白导入」的隐性放大器——回退可用,但必须以日志/提示明示。
3. **前置校验放服务层 + 执行器双保险**:前端拦截提升体验,服务层拒绝保证主链路,
执行器兜底封死绕过路径(batch/脚本/API 直调)。
4. **登录是压测计量接口**:预登录/启动登录/重登全路径都要进 `api_summary`
否则登录瓶颈(验证码接口、鉴权服务)在报告中不可见,压测结论失真。
---
*本文档由 Claude Code 生成*
\ No newline at end of file
......@@ -1130,7 +1130,15 @@ class PerformanceExecutor:
self._vu_tokens.clear()
self._vu_login_failed.clear()
if task.auth_required and not per_vu_login:
# 共享预登录计入接口维度监控(api_summary「登录」),登录是压测计量接口
_login_start = time.time()
token = self._ensure_token(task)
_login_elapsed = (time.time() - _login_start) * 1000
self._metrics.record(
200 if token else 0, _login_elapsed, bool(token),
"" if token else f"登录失败 (account: {task.account_key})",
api_name="登录",
)
if not token:
self._running = False
return {
......@@ -1728,25 +1736,36 @@ class PerformanceExecutor:
if getattr(task, "csv_parameterization_enabled", False):
csv_records, csv_fields = self._parse_csv(task)
# per-VU 独立登录模式:必须通过 CSV 提供 username/password 列
# 混合场景必须导入 CSV 账号数据(不分 per-VU):
# 服务层已前置拦截,这里兜底封死 batch/脚本/API 直调执行器的路径
if not csv_records or not csv_fields:
return {"error": True,
"error_message": "混合场景必须启用 CSV 参数化并导入账号数据,否则不允许执行"}
# 列映射/缺列校验先于账号数校验:缺列是配置错误,优先于数量问题报告
mapping = getattr(task, "csv_variable_mapping", None) or {}
user_col = self._resolve_csv_column_name(mapping.get("username", ""))
pwd_col = self._resolve_csv_column_name(mapping.get("password", ""))
# 列名必须真实存在于 CSV 表头;未配置映射或列名不符时按字面 username/password 兜底
if not user_col or user_col not in csv_fields:
user_col = "username" if "username" in csv_fields else None
if not pwd_col or pwd_col not in csv_fields:
pwd_col = "password" if "password" in csv_fields else None
if not mapping.get("username") or not mapping.get("password"):
logger.warning(
f"[mix][{getattr(task, 'id', '')}] csv_variable_mapping 为空或未映射 "
f"username/password,将按字面列名解析登录凭据;"
f"若表头不是该命名请配置字段映射"
)
per_vu_login = getattr(task, "per_vu_login", False)
if per_vu_login:
if not csv_records or not csv_fields:
return {"error": True, "error_message": "per-VU 独立登录必须启用 CSV 参数化并导入账号数据"}
mapping = getattr(task, "csv_variable_mapping", None) or {}
user_col = self._resolve_csv_column_name(mapping.get("username", ""))
pwd_col = self._resolve_csv_column_name(mapping.get("password", ""))
# 列名必须真实存在于 CSV 表头;未配置映射或列名不符时按字面 username/password 兜底
if not user_col or user_col not in csv_fields:
user_col = "username" if "username" in csv_fields else None
if not pwd_col or pwd_col not in csv_fields:
pwd_col = "password" if "password" in csv_fields else None
if not user_col or not pwd_col:
return {"error": True,
"error_message": "CSV 缺少 username/password 列(per-VU 独立登录需要)"}
if len(csv_records) < task.concurrency:
return {"error": True,
"error_message": f"CSV 账号数({len(csv_records)})少于虚拟用户数({task.concurrency}),不允许执行"}
if per_vu_login and (not user_col or not pwd_col):
return {"error": True,
"error_message": "CSV 缺少 username/password 列(per-VU 独立登录需要)"}
if len(csv_records) < task.concurrency:
return {"error": True,
"error_message": (f"CSV 账号数({len(csv_records)})少于"
f"虚拟用户数({task.concurrency}),不允许执行")}
try:
async with aiohttp.ClientSession(connector=connector) as session:
......@@ -1841,8 +1860,16 @@ class PerformanceExecutor:
per_vu_login = getattr(task, "per_vu_login", False)
bearer_token = None
if per_vu_login:
# 启动登录计入接口维度监控(api_summary「登录」)——登录是压测计量接口
_login_start = time.time()
token = await self._ensure_vu_token(task, global_thread_idx,
csv_records, csv_fields)
_login_elapsed = (time.time() - _login_start) * 1000
self._metrics.record(
200 if token else 0, _login_elapsed, bool(token),
"" if token else "登录失败",
api_name="登录",
)
if not token:
# 启动登录失败:该虚拟用户不发业务请求(计失败)
logger.warning(
......@@ -2085,6 +2112,8 @@ class PerformanceExecutor:
# 登录接口识别(mix 场景:传入 api_config.url 即可识别随机池中的登录接口)
is_login = self._is_login_target(task, url_override=url)
# 登录统一计入 api_summary「登录」(随机池重登 / 单接口登录压测)
metrics_api_name = api_name if not is_login else "登录"
# 登录接口使用自动构造的请求体(含加密密码 + 验证码UUID)
# 支持 CSV 参数化:启用后按线程索引取不同虚拟用户的用户名/密码
......@@ -2159,12 +2188,12 @@ class PerformanceExecutor:
api_assertions, status, body, elapsed_ms
)
# 记录指标(长稳压测带 api_name 用于接口维度统计)
# 记录指标(长稳压测带 api_name 用于接口维度统计;登录统一归「登录」
self._metrics.record(
status, elapsed_ms, assert_ok,
latency_ms=latency_ms, connect_ms=connect_ms,
sent_bytes=sent_bytes, received_bytes=received_bytes,
api_name=api_name,
api_name=metrics_api_name,
)
# 响应捕获(从响应中提取字段保存到 _captured)
......@@ -2190,7 +2219,7 @@ class PerformanceExecutor:
self._metrics.record(
0, elapsed_ms, False, "timeout",
sent_bytes=sent_bytes, error_type="timeout",
api_name=api_name,
api_name=metrics_api_name,
)
except aiohttp.ClientConnectorError as e:
elapsed_ms = (time.time() - start) * 1000
......@@ -2199,7 +2228,7 @@ class PerformanceExecutor:
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="connect_error",
api_name=api_name,
api_name=metrics_api_name,
)
except aiohttp.ClientError as e:
elapsed_ms = (time.time() - start) * 1000
......@@ -2208,7 +2237,7 @@ class PerformanceExecutor:
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
api_name=api_name,
api_name=metrics_api_name,
)
except Exception as e:
elapsed_ms = (time.time() - start) * 1000
......@@ -2217,7 +2246,7 @@ class PerformanceExecutor:
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
api_name=api_name,
api_name=metrics_api_name,
)
finally:
self._inflight -= 1
......
......@@ -353,6 +353,10 @@ class PerformanceTaskListItem(BaseModel):
scenario_type: Optional[str] = None
target_url: str
project_id: Optional[str] = None
# mix 场景前端「执行」前置拦截需要(没有导入 CSV 不允许执行)
csv_parameterization_enabled: bool = False
csv_content: Optional[str] = None
per_vu_login: bool = False
total_requests: int = 0
actual_tps: float = 0.0
peak_tps: float = 0.0
......
......@@ -323,6 +323,11 @@ class PerformanceService:
if hasattr(task, key):
setattr(task, key, value)
# 前置校验:mix 场景必须导入 CSV 账号数据(batch/API 等后台路径兜底拦截)
err = self._validate_run_preconditions(task)
if err:
raise ValueError(err)
# 创建/复用执行记录
if execution_id:
# 复用 run_task_async 已创建的记录(避免双条:一条 running 卡死 + 一条真实执行)
......@@ -1244,6 +1249,43 @@ class PerformanceService:
# ==================== 异步非阻塞执行 ====================
@staticmethod
def _validate_run_preconditions(task) -> Optional[str]:
"""
执行前前置校验:
1. 目标机资源监控开关必须开启(否则目标机资源图无数据)
2. mix 场景必须导入 CSV 账号数据。
mix 场景每个虚拟用户都从 CSV 取账号数据(per-VU 登录 / 请求体参数化),
没有导入 CSV 或账号数不足时不允许执行,返回明确错误信息。
PDF 需求:混合场景没有导入 CSV 就不允许执行。
Args:
task: PerformanceTask 任务对象
Returns:
str: 错误信息;None 表示校验通过
"""
# 目标机资源监控开关校验:未开启时执行完才发现无监控数据,浪费一次压测
from app.config import settings
if not settings.TARGET_MONITOR_ENABLED:
return ("目标机资源监控未开启(TARGET_MONITOR_ENABLED=false),"
"执行期间将采集不到目标机(被测系统)资源数据,请先在服务器环境变量开启")
if getattr(task, "scenario_type", None) != "mix":
return None
if not getattr(task, "csv_parameterization_enabled", False):
return "混合场景必须启用 CSV 参数化并导入账号数据,否则不允许执行"
records, fields = PerformanceExecutor._parse_csv(task)
if not records or not fields:
return "混合场景必须导入 CSV 账号数据(表头 + 至少 1 行账号),当前为空"
n_accounts = len(records)
concurrency = getattr(task, "concurrency", 0) or 0
if n_accounts < concurrency:
return (f"混合场景 CSV 账号数({n_accounts})少于虚拟用户数({concurrency}),"
f"不允许执行")
return None
async def run_task_async(
self,
task_id: str,
......@@ -1273,6 +1315,11 @@ class PerformanceService:
if task.status == "running":
raise ValueError("任务正在执行中,不能重复启动")
# 前置校验:mix 场景必须导入 CSV 账号数据(不通过则直接拒绝执行)
err = self._validate_run_preconditions(task)
if err:
raise ValueError(err)
# 创建执行记录(running 状态)
execution_id = generate_id("exec")
execution = PerformanceExecution(
......
......@@ -52,6 +52,9 @@ services:
- PLAYWRIGHT_TIMEOUT=30000
- LOG_LEVEL=INFO
- TZ=Asia/Shanghai
# 目标机资源监控(SSH 采集被测系统 192.168.5.44)。采样已走线程池不阻塞事件循环,
# 关闭会导致压测全程无目标机资源数据(2026-09-02 混合场景无监控数据问题根因)
- TARGET_MONITOR_ENABLED=true
volumes:
# 后端源码(更新后端只需重启容器,无需重建镜像)
- /data/third_party/plat-auto-test/backend:/app
......
......@@ -546,6 +546,10 @@ export interface PerformanceTaskListItem {
createdAt: string
/** 场景类型(列表标识) */
scenarioType?: PerfScenarioType
/** CSV 参数化(mix 场景「执行」前置拦截需要) */
csvParameterizationEnabled?: boolean
csvContent?: string | null
perVuLogin?: boolean
}
/** 任务列表响应 */
......
......@@ -414,7 +414,7 @@
</div>
<el-empty
v-show="!targetAvailable"
description="目标机资源监控数据不可用(SSH 连接失败或未开启)"
description="目标机资源监控数据不可用(SSH 连接失败或未开启 TARGET_MONITOR_ENABLED)"
:image-size="80"
/>
</el-collapse-item>
......@@ -647,8 +647,11 @@ async function loadReplayData() {
if (!resourceExpanded.value.includes('resource')) {
resourceExpanded.value.push('resource')
}
updateResourceChart()
updateNetworkChart()
// nextTick 等折叠面板展开后再渲染(同实时路径,避免零尺寸画布)
nextTick(() => {
updateResourceChart()
updateNetworkChart()
})
}
if (targetResourceTimeLabels.value.length > 0) {
if (!targetResourceExpanded.value.includes('targetResource')) {
......@@ -819,6 +822,9 @@ const targetResourceExpanded = ref<string[]>([])
/** 折叠面板是否已自动展开过(每次连接重置,避免用户手动收起后被再次强制展开) */
const resourceAutoExpanded = ref(false)
const targetAutoExpanded = ref(false)
/** 运行中连续未收到目标机数据的快照计数(超阈值自动展开空态提示,每次连接重置) */
let noTargetSnapshotCount = 0
const targetEmptyAutoExpanded = ref(false)
let tpsChart: echarts.ECharts | null = null
let rtChart: echarts.ECharts | null = null
......@@ -972,18 +978,43 @@ function updateStatusChart() {
statusChart.setOption(option, true)
}
/** 读取图表当前缩放窗口(用户拖动缩放后,每秒刷新不应重置) */
function getCurrentZoom(chart: echarts.ECharts | null): { start: number; end: number } | null {
if (!chart) return null
try {
const opt = chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> }
const dz = opt?.dataZoom?.[0]
if (dz && typeof dz.start === 'number' && typeof dz.end === 'number'
&& (dz.start !== 0 || dz.end !== 100)) {
return { start: dz.start, end: dz.end }
}
} catch {
// 图表尚未 setOption 时无 dataZoom 状态,忽略
}
return null
}
/** 更新 CPU & 内存趋势图 */
function updateResourceChart() {
if (!resourceChart && resourceChartRef.value) {
resourceChart = echarts.init(resourceChartRef.value)
}
if (!resourceChart) return
// 折叠状态下 init 得到零尺寸画布,展开后必须 resize 重读容器,否则 X 轴被压成窄条
if (resourceChart.getWidth() === 0 || resourceChart.getHeight() === 0) {
resourceChart.resize()
}
const zoom = getCurrentZoom(resourceChart)
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['CPU %', 'Memory %'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
grid: { left: 50, right: 20, bottom: 55, top: 30 },
xAxis: { type: 'category', data: resourceTimeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: '%', max: 100 },
dataZoom: [
{ type: 'inside', start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
{ type: 'slider', height: 16, bottom: 8, start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
],
series: [
{ name: 'CPU %', type: 'line', data: cpuData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#f56c6c' }, areaStyle: { opacity: 0.08 } },
{ name: 'Memory %', type: 'line', data: memData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#409eff' }, areaStyle: { opacity: 0.08 } },
......@@ -998,12 +1029,21 @@ function updateNetworkChart() {
networkChart = echarts.init(networkChartRef.value)
}
if (!networkChart) return
// 同上:零尺寸画布兜底 resize
if (networkChart.getWidth() === 0 || networkChart.getHeight() === 0) {
networkChart.resize()
}
const zoom = getCurrentZoom(networkChart)
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发送 Sent', '接收 Recv'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
grid: { left: 50, right: 20, bottom: 55, top: 30 },
xAxis: { type: 'category', data: resourceTimeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: 'KB/s' },
dataZoom: [
{ type: 'inside', start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
{ type: 'slider', height: 16, bottom: 8, start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
],
series: [
{ name: '发送 Sent', type: 'line', data: netSentData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#67c23a' }, areaStyle: { opacity: 0.08 } },
{ name: '接收 Recv', type: 'line', data: netRecvData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#e6a23c' }, areaStyle: { opacity: 0.08 } },
......@@ -1023,6 +1063,7 @@ function updateNetworkChart() {
if (targetResourceChart.getWidth() === 0 || targetResourceChart.getHeight() === 0) {
targetResourceChart.resize()
}
const zoom = getCurrentZoom(targetResourceChart)
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: {
......@@ -1030,7 +1071,11 @@ function updateNetworkChart() {
top: 0, itemWidth: 12, itemHeight: 8,
type: 'scroll',
},
grid: { left: 50, right: 20, bottom: 30, top: 30 },
grid: { left: 50, right: 20, bottom: 55, top: 30 },
dataZoom: [
{ type: 'inside', start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
{ type: 'slider', height: 16, bottom: 8, start: zoom?.start ?? 0, end: zoom?.end ?? 100 },
],
xAxis: { type: 'category', data: targetResourceTimeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '%', max: 100 },
......@@ -1112,8 +1157,11 @@ function handleSnapshot(data: any) {
}
resourceAutoExpanded.value = true
updateResourceChart()
updateNetworkChart()
// nextTick 等折叠面板 v-show 生效后再渲染,避免零尺寸画布把 X 轴压成窄条
nextTick(() => {
updateResourceChart()
updateNetworkChart()
})
}
// 目标机资源监控数据
......@@ -1189,6 +1237,17 @@ function handleSnapshot(data: any) {
// 用 nextTick 确保 v-show 已生效、div 可见后再更新图表,避免零尺寸空白
nextTick(() => updateTargetResourceChart())
noTargetSnapshotCount = 0
} else {
// 运行中但快照持续无目标机数据(监控未开启或 SSH 连接失败):
// 超过 10 个快照(约 10s)自动展开一次面板,让用户看到空态原因,而不是默默无数据
noTargetSnapshotCount++
if (!targetEmptyAutoExpanded.value && isRunning.value && noTargetSnapshotCount >= 10) {
if (!targetResourceExpanded.value.includes('targetResource')) {
targetResourceExpanded.value.push('targetResource')
}
targetEmptyAutoExpanded.value = true
}
}
// 限制显示点数
......@@ -1301,6 +1360,8 @@ function connectWs() {
javaProcessAvailable.value = false
resourceAutoExpanded.value = false
targetAutoExpanded.value = false
noTargetSnapshotCount = 0
targetEmptyAutoExpanded.value = false
connectionStatus.value = 'connecting'
......@@ -1371,7 +1432,20 @@ watch(() => route.query.executionId, (newExecId) => {
}
})
/** 窗口尺寸变化时所有图表自适应(折叠面板展开后宽度变化也依赖 update 时的零尺寸兜底) */
function handleWindowResize() {
tpsChart?.resize()
rtChart?.resize()
statusChart?.resize()
latencyChart?.resize()
inflightChart?.resize()
resourceChart?.resize()
networkChart?.resize()
targetResourceChart?.resize()
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
if (taskId.value) {
// 统一先尝试 WebSocket 实时连接
// 如果任务已结束,WebSocket 连接会快速返回,handleComplete 自动回退到 loadReplayData
......@@ -1387,6 +1461,7 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
disconnectWs()
if (countdownTimer) {
clearInterval(countdownTimer)
......
......@@ -1194,14 +1194,18 @@ function handleXlsxUpload(file: File): boolean {
return false
}
/** 下载 CSV 用户数据模板(xlsx) */
/** 下载 CSV 用户数据模板(xlsx)——前端动态生成,无需部署静态文件 */
function downloadCsvTemplate() {
const a = document.createElement('a')
a.href = '/模板_CSV用户数据.xlsx'
a.download = '模板_CSV用户数据.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet([
['username', 'password'],
['user1', 'pass1'],
['user2', 'pass2'],
])
// 表头列加宽,提升可读性
ws['!cols'] = [{ wch: 20 }, { wch: 20 }]
XLSX.utils.book_append_sheet(wb, ws, 'CSV用户数据')
XLSX.writeFile(wb, '模板_CSV用户数据.xlsx')
}
/** 保存任务 */
......@@ -1303,6 +1307,14 @@ async function handleSave() {
/** 执行任务 — 非阻塞返回后立即跳转监控页 */
async function handleRun(task: PerformanceTaskListItem) {
// 混合场景前置拦截:必须导入 CSV 账号数据(与服务层校验一致,避免空发请求)
if (task.scenarioType === 'mix') {
const hasCsv = !!task.csvParameterizationEnabled && !!task.csvContent
if (!hasCsv) {
ElMessage.warning('混合场景必须先导入 CSV 账号数据(账号数需 ≥ 并发数),否则不允许执行')
return
}
}
runningId.value = task.id
try {
const res = await runTask(task.id)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论