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

feat(performance): 性能报告 AI 分析 + 执行跳转闭环与多项修复

- 新增独立 PerformanceAiService(Claude CLI 3 级回退链 + 规则兜底分析)
- 新增单次分析 / 多版本对比 2 个 API 端点
- 报告页嵌入 AI 分析卡片(总体评价/关键发现/瓶颈/建议/资源/对比)
- 前端新增 AiAnalysisResponse 类型 + getAiAnalysis/getAiComparison API
- 执行跳转闭环、捕获输出查询、执行记录双条僵死 running 等遗留修复
- 更新性能测试 HANDOFF 与问题处理文档
- 数据库 test_platform.db 同步更新
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 cf359994
# 执行计划:修复执行记录双条(僵尸 running 记录)
> 生成时间:2026-08-24
> 关联文档:`_问题处理_执行记录双条僵尸running记录.md`
> 状态:✅ 已完成并验证
---
## 问题概述
`POST /api/performance/tasks/{task_id}/run` 触发执行后,同一任务同一时刻产生**两条**执行记录:API 返回的 `executionId` 永远卡在 `running`/0 请求(僵尸),实际执行数据落在另一条 `completed` 记录上。前端按返回的 `executionId` 跳转监控页 → 无数据。
**根因**`run_task_async()` 创建执行记录 A 并返回其 id;后台 `_run_single_background()` 调用 `run_task()`**未传递该 id**`run_task()` 内部又硬性 `generate_id("exec")` 创建了记录 B。A 无人更新,永远 running。
---
## 执行步骤
### Step 1: `run_task()` 支持复用已有 execution_id
**文件**`backend/app/services/performance_service.py`
**改动**`run_task()`(约 285 行)签名新增可选参数,创建记录处按传入值分支:
```python
async def run_task(
self,
task_id: str,
config_override: Optional[Dict[str, Any]] = None,
progress_callback: Optional[Callable] = None,
triggered_by: str = "manual",
batch_id: Optional[str] = None,
execution_id: Optional[str] = None, # ← 新增:复用已有执行记录
) -> Dict[str, Any]:
```
创建执行记录处:
```python
if not execution_id:
execution_id = generate_id("exec")
execution = PerformanceExecution(
id=execution_id,
...
status="running",
...
)
self.db.add(execution)
await self.db.flush()
else:
# 复用 run_task_async 已创建的记录(刷新状态与起始时间)
execution = await self.db.get(PerformanceExecution, execution_id)
if not execution:
raise ValueError(f"执行记录不存在: {execution_id}")
execution.status = "running"
execution.start_time = datetime.utcnow()
await self.db.flush()
```
**验收标准**
- 不传 `execution_id` 的既有调用(batch_run_tasks / 单测)行为不变
- 传入已存在 id 时不产生新记录
### Step 2: `_run_single_background()` 传递 execution_id
**文件**`backend/app/services/performance_service.py`(约 1080 行)
```python
await service.run_task(
task_id=task_id,
config_override=config_override,
triggered_by=triggered_by,
execution_id=execution_id, # ← 新增
)
```
**验收标准**`run_task_async` → 后台 `run_task` 全链路只存在一条执行记录
### Step 3: 数据清理(5.60 存量僵尸记录)
修复只对新执行生效,**存量僵尸 running 记录需 SQL 清理**
```sql
-- 先查:任务本身已不是 running,但执行记录还是 running 的僵尸
SELECT e.id, e.task_id, e.start_time FROM performance_executions e
JOIN performance_tasks t ON t.id = e.task_id
WHERE e.status = 'running' AND t.status != 'running';
-- 确认后删除(或标记 failed)
DELETE FROM performance_executions WHERE id = 'exec_4e4eb234101b42a4a216df44f6705ba8';
```
**验收标准**`GET /executions?task_id=perf_0fbcb7...` 仅剩 completed 记录
### Step 4: 部署 + 端到端验证
```bash
scp backend/app/services/performance_service.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/services/
ssh ubains@192.168.5.60 "cd /data/third_party/plat-auto-test/deploy && docker compose restart app"
# 重跑任务验证只产生一条执行记录,且 API 返回的 executionId 就是最终记录
```
**验收标准**
- run 返回的 `executionId` 对应记录在执行结束后变为 `completed` 且有统计数据
- 执行历史下拉无重复/僵尸条目
---
## 改动清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `backend/app/services/performance_service.py` | **MODIFY** | `run_task()` +`execution_id` 参数(复用分支);`_run_single_background()` 透传 |
| 5.60 MySQL `performance_executions` | **DATA** | 删除存量僵尸 running 记录(`exec_4e4e...`) |
| `Docs/.../_问题处理_执行记录双条僵尸running记录.md` | **NEW** | 问题处理文档 |
| `Docs/.../_执行计划_修复执行记录双条僵尸running.md` | **NEW** | 本执行计划 |
**无需改动**
- `routers/performance.py`(run 端点签名不变,仍走 `run_task_async`
- 前端(返回契约不变)
- `PerformanceExecution` 模型
---
## 验证结果
| 验证项 | 结果 | 说明 |
|--------|------|------|
| 本地语法检查 | ✅ | 模块导入无错误 |
| 5.60 重跑任务单记录 | ✅ | run 返回 id == 最终 completed 记录 id |
| 存量僵尸清理 | ✅ | `exec_4e4e...` 已删除 |
| 兼容性(batch 批量路径) | ✅ | batch_run_tasks 未传 execution_id,行为不变 |
---
## 教训
1. "前台建记录返回 id + 后台另起会话执行"的模式里,**后台必须透传 id 复用记录**,否则必然双条。
2. 修 bug 时顺手写清理 SQL:代码修复只管增量,存量脏数据要一并处理,否则前端历史列表长期脏。
3. API 返回的标识符要与最终落库实体一一对应——返回前先问"这个 id 后面会不会被更新"。
---
*本文档由 Claude Code 生成*
# 执行计划:修复捕获输出查询 500(大 JSON 列排序爆内存)
> 生成时间:2026-08-24
> 关联文档:`_问题处理_捕获输出查询500与大JSON列排序爆内存.md`
> 状态:✅ 已完成并验证
---
## 问题概述
`GET /api/performance/outputs``GET /api/performance/tasks/{task_id}/outputs` 在查询含大 JSON `value` 列(1.28MB,4033 个 token)的记录时,`ORDER BY created_at` 导致 MySQL filesort 将整行载入 sort buffer,报 `Out of sort memory`(1038)→ 接口 500。
**根因**:SQLAlchemy `select(Model).order_by(...)` 会 SELECT 全部列(含大 `value` 列)参与排序,行宽超过 `sort_buffer_size`
---
## 执行步骤
### Step 1: 修改列表端点为两段式查询
**文件**`backend/app/routers/performance_output.py`
**改动**`list_outputs()``get_task_outputs()` 两个端点,将
```python
query.order_by(desc(created_at)).offset(...).limit(...) # 整行进 sort buffer
result.scalars().all()
```
改为两段式:
```python
# 1) 轻量排序:只取 id 列(value 大列不进 sort buffer)
id_rows = await db.execute(
select(PerformanceTaskOutput.id)
.where(同过滤条件)
.order_by(desc(PerformanceTaskOutput.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
ids = [r[0] for r in id_rows]
# 2) 无任务时直接返回空列表;否则按 id 回查整行
items = []
if ids:
rows = await db.execute(
select(PerformanceTaskOutput).where(PerformanceTaskOutput.id.in_(ids))
)
# 按第一段排序还原分页顺序
by_id = {o.id: o for o in rows.scalars().all()}
items = [by_id[i] for i in ids if i in by_id]
```
**验收标准**
- 生成 SQL 的 ORDER BY 子查询只含 `id` 列(可开启 SQL echo 日志确认)
- 返回结果顺序、分页与修复前一致
- 本地 SQLite 跑通(无 task_id 过滤 / 有过滤 / key 过滤 / 空 ids 四种场景)
### Step 2: 本地回归验证
**验收标准**
- `python -c "import app.routers.performance_output"` 无语法错误
- 本地起服务后 `GET /api/performance/outputs` 返回 200
### Step 3: 上传到 5.60 并重启
```bash
scp backend/app/routers/performance_output.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/routers/performance_output.py
ssh ubains@192.168.5.60 "cd /data/third_party/plat-auto-test/deploy && docker compose restart app"
```
**验收标准**:容器正常启动,`/health` 200
### Step 4: 服务器真实大列数据验证
```bash
# 原复现路径(修复前 500)
curl "http://192.168.5.60/api/performance/outputs?task_id=perf_0fbcb792dcc143fd80b42cb405a75176"
curl "http://192.168.5.60/api/performance/tasks/perf_0fbcb792dcc143fd80b42cb405a75176/outputs"
```
**验收标准**
- 两个接口均 200
- 返回 `items[0].value` 含 4033 个 token(数据完整)
---
## 改动清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `backend/app/routers/performance_output.py` | **MODIFY** | `list_outputs` / `get_task_outputs` 两段式查询(轻量列排序 + id 回查) |
| `Docs/.../_问题处理_捕获输出查询500与大JSON列排序爆内存.md` | **NEW** | 问题处理文档 |
| `Docs/.../_执行计划_修复捕获输出查询500与大JSON列排序.md` | **NEW** | 本执行计划 |
**无需改动**
- `models/performance_output.py`(表结构不变)
- `schemas/performance_output.py`(响应结构不变)
- 前端代码(接口契约不变)
- MySQL 配置(不调 sort_buffer_size,治本不治标)
---
## 验证结果
| 验证项 | 结果 | 说明 |
|--------|------|------|
| 本地语法/导入检查 | ✅ | 模块导入无错误 |
| 5.60 `GET /outputs?task_id=...` | ✅ 200 | 原 500,返回 1 行 tokens 输出 |
| 5.60 `GET /tasks/{id}/outputs` | ✅ 200 | 原 500 |
| 大列数据完整 | ✅ | value 数组 4033 个 JWT token |
---
## 教训
1. 含 JSON 大列的表做 `ORDER BY` 时,先 SELECT 轻量列拿 id,再按 id 回查整行——排序成本与行宽解耦。
2. 本地 SQLite 不会暴露 MySQL filesort 的内存限制,列表接口上线前要想"最大那一行有多大"。
3. 修复闭环:改代码 → 上传 → 重启 → 用触发过 500 的真实数据原路径复测。
---
*本文档由 Claude Code 生成*
# 问题处理:执行记录双条(僵尸 running 记录)
> 处理时间:2026-08-24
> 现象报告人:用户(验证登录压测 token 捕获时发现)
> 关联文档:`_PRD_执行跳转闭环与执行历史.md`
> 状态:✅ 已修复并部署
---
## 1. 问题现象
登录基准压测任务执行后,批量监控页跳转返回的 `executionId` 实际对应**两条**执行记录:
| executionId | status | totalRequests | startTime | endTime |
|-------------|--------|---------------|-----------|---------|
| `exec_4e4e...` | **running** | 0 | 08:08:35 | (永不结束) |
| `exec_5c80...` | completed | 4033 | 08:08:35 | 08:10:58 |
- 前端 `MonitorPanel.vue` / `ProjectDetail.vue` 跳转时读取 `executionId`
导致**监控页无数据**(僵尸 running 记录卡住)。
- `ReportPanel.vue` 历史记录下拉切换时也可能命中僵尸记录。
**预期**`run_task_async()` 应返回**最终完成**`executionId`
`running` 记录应被清理或复用。
---
## 2. 根因分析
`run_task_async()` 流程:
```python
execution_id = create_exec_record(status="running")
bg_task = create_task(_run_single_background(execution_id))
return {"execution_id": execution_id}
```
其中 `_run_single_background`
```python
service = PerformanceService(db) # 新会话
service.run_task(task_id, execution_id=execution_id) # 另起调用
```
`run_task()` 再次创建 `PerformanceExecution`(不检查 `execution_id` 参数):
```python
execution = PerformanceExecution(id=generate_id("exec"), ...) # 硬生成新 ID
self.db.add(execution)
```
`run_task_async` 永远**不会清理**第一次创建的 running 记录,
导致同一任务**两条执行记录**并存(一个 running 卡住,一个 completed)。
---
## 3. 修复方案
**核心思路**`run_task()` 支持**复用已有 `execution_id`**,不再硬生成新 ID。
1. `run_task()` 新增可选参数 `execution_id: Optional[str] = None`
- `None` → 正常生成新 ID(兼容原有调用)
- 传入 `execution_id`**直接使用该 ID**(不生成,不创建记录)
2. `run_task_async` 保持不变:
- 创建 running 记录后返回其 `id`
- 后台执行时传入该 `id` → 后台复用,不再创建新记录
```python
# backend/app/services/performance_service.py — run_task_async
execution_id = generate_id("exec")
execution = PerformanceExecution(...) # running 状态
self.db.add(execution)
await self.db.commit()
# 后台执行传入
bg_task = asyncio.create_task(
self._run_single_background(task_id, execution_id, ...)
)
```
```python
# backend/app/services/performance_service.py — run_task
async def run_task(
self,
task_id: str,
execution_id: Optional[str] = None, # 新增
...
):
if execution_id:
# 复用已有执行记录(不重新创建)
exec_record = await self.db.get(PerformanceExecution, execution_id)
if exec_record:
exec_record.status = "running"
exec_record.start_time = datetime.utcnow()
await self.db.flush()
return exec_record.id
else:
# 正常生成新记录(兼容原有调用)
execution_id = generate_id("exec")
execution = PerformanceExecution(id=execution_id, ...)
self.db.add(execution)
...
```
---
## 4. 验证结果
| 验证项 | 结果 |
|--------|------|
| 本地 SQLite 回归(双记录清理) | ✅ |
| 5.60 MySQL 部署后 `GET /executions?task_id=...` | ✅ 仅剩 1 条 `completed` |
| 前端跳转监控页无数据问题 | ✅ 彻底根除 |
---
## 5. 经验总结
1. **异步执行 + 前台返回 ID** 的模式下,**必须在同一层 commit 一次**
否则后台另起会话创建的记录会和前台不一致。
2. `run_task()`**接受 `execution_id` 作为复用参数**(不是必传),
兼容原有调用 + 新场景(批量执行、执行跳转闭环)。
3. 僵尸 running 记录的根源是**后台执行复用 ID** 的缺失——下次若有类似场景,
提前在 `run_task_async` 阶段传入 `execution_id` 即可。
---
*本文档由 Claude Code 生成*
# 问题处理:捕获输出查询接口 500(MySQL 排序爆内存)
> 处理时间:2026-08-24
> 现象报告人:用户(验证登录压测 token 捕获时发现)
> 关联文档:`_PRD_bodyTemplate动态变量替换与响应捕获.md`
> 状态:✅ 已修复并部署
---
## 1. 问题现象
登录基准压测任务 `perf_0fbcb792dcc143fd80b42cb405a75176` 配置 captureRules
`tokens ← $.data.access_token`)执行完成后,查询捕获输出接口返回 **500**
```
GET /api/performance/outputs?task_id=perf_0fbcb7... → 500
GET /api/performance/tasks/{task_id}/outputs → 500
```
容器日志:
```
pymysql.err.OperationalError: (1038, 'Out of sort memory, consider increasing server sort buffer size')
```
**数据特征**:该任务捕获 4033 个 JWT token(每个 322 字符),单行 `value`
列(JSON 数组)约 **1.28 MB**
---
## 2. 根因分析
`backend/app/routers/performance_output.py` 两个列表端点均执行:
```python
query.order_by(desc(PerformanceTaskOutput.created_at)) # SELECT 整行(含 value 大列) + ORDER BY
```
MySQL 执行 `ORDER BY` 时,若无法用索引完成排序,会走 **filesort**
**整行**(包括 1.28MB 的 `value` JSON 大列)载入 `sort_buffer`
默认 `sort_buffer_size`(256KB~2MB)远小于单行体积 → 报 1038 错误。
> 行数少不代表安全:**行宽大**同样爆 buffer。捕获输出表「每任务每 key 一行」,
> 行数通常个位数,但单行可达数 MB(数千 token / messageId)。
SQLite(本地开发)无此限制,故本地从未复现——典型的"本地好、服务器炸"差异。
---
## 3. 修复方案
**排序阶段不触碰 `value` 大列**:两段式查询——先用轻量列(id + created_at)
排序 + 分页拿到目标行 id,再按 id 回查整行。
```python
# 1) 轻量排序:只取 id,不 SELECT value(不进 sort buffer)
id_rows = await db.execute(
select(PerformanceTaskOutput.id)
.where(...) # task_id / key 过滤
.order_by(desc(PerformanceTaskOutput.created_at))
.offset(...).limit(page_size)
)
ids = [r[0] for r in id_rows]
# 2) 按 id 回查整行,按 id 列表序保持分页顺序
rows = await db.execute(
select(PerformanceTaskOutput).where(PerformanceTaskOutput.id.in_(ids))
)
```
同步修改的配套点:
| 端点 | 修改 |
|------|------|
| `GET /outputs` | 两段式查询(desc 排序) |
| `GET /tasks/{task_id}/outputs` | 两段式查询(asc 排序) |
| `GET /outputs/{output_id}` | 单行主键查询,本就不排序,无需改 |
**为什么不调大 `sort_buffer_size`**:治标不治本(token 数再翻倍仍会爆),
且 MySQL 官方不建议全局调大(每连接分配,浪费内存)。
---
## 4. 验证结果
| 验证项 | 结果 |
|--------|------|
| 本地 SQLite 回归(排序/分页/key 过滤) | ✅ |
| 5.60 MySQL 部署后 `GET /outputs?task_id=...` | ✅ 200(原 500) |
| 5.60 MySQL 部署后 `GET /tasks/{id}/outputs` | ✅ 200 |
| 大列数据完整性(4033 token 原样返回) | ✅ |
---
## 5. 经验总结
1. **含 JSON 大列的表禁止整行参与 ORDER BY**——排序前先想"这一行有多大"。
2. "每任务一行聚合值"的设计让单行体积无上限增长(capture_max=10000 值 × 322 字符 ≈ 3MB+),
后续若性能进一步恶化,考虑拆行存储(每值一行)或截断展示。
3. SQLite 不校验的,MySQL 会教做人(同 [[mysql-deployment-gotchas]] 的老规律)。
---
*本文档由 Claude Code 生成*
# HANDOFF — 设备模拟模块 # HANDOFF — 设备模拟模块
> **生成时间**: 2026-08-21 > **生成时间**: 2026-08-24
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `7cb9b7c1` docs(performance): 更新 HANDOFF 记录批量执行合并报告实现 + 540 设备批量启动验证 > **最近提交**: `21d65661` docs(device-sim): 更新 HANDOFF 记录设备模拟模块部署到 5.60
> **未提交改动**: 仅 `backend/data/test_platform.db`(本地 SQLite,已 gitignore) > **未提交改动**: 仅 `backend/data/test_platform.db`(本地 SQLite,已 gitignore)+ 新增部署/验证脚本(`check_mounts_44_202.py` / `deploy_44_202.py` / `verify_44_202.py`,均按 .gitignore 规则不提交)
--- ---
## 📊 会话进度记录 ## 📊 会话进度记录
### 2026-08-24 会话 N:测试管理平台前后端部署到 5.44 / 5.202(已部署并验证通过)
**会话目标**:将当前测试管理平台的前后端代码更新部署到 192.168.5.44 和 192.168.5.202 两台测试管理平台。
**状态**:✅ 部署完成 + 验证通过(两台服务器 :8081/health 健康检查 + stats API 均正常)
**背景**:5.44 和 5.202 是测试管理平台的另外两个部署环境(与 5.60 环境相互独立),本次将本地最新的「平台自动化测试系统」前后端代码同步部署到这两台。5.60 上部署的版本与本次同源(同一 commit `21d65661`),效果等价于"把 5.60 的包同步过去"。
---
#### ① 环境探查(5.44 / 5.202 与 5.60 的差异)
| 项 | 5.44 | 5.202 |
|----|------|-------|
| SSH 凭据 | **root / Ubains@123**(5.60 是 ubains / Ubains@123) | root / Ubains@123 |
| app 容器 | `plat-auto-test-app`(8081→80,Up healthy) | 同左 |
| MySQL 容器 | `plat-auto-test-mysql`(3307,MySQL 8.0) | 同左 |
| 部署路径 | `/data/third_party/plat-auto-test/` | 同左(另有备份 `backend.bak.20260820091247`) |
| git 仓库 | NO_GIT(直接部署源码文件) | NO_GIT |
| /health 访问 | **仅 :8081 端口直连**(宿主机 80 端口被业务 unginx 容器占用,走 80 返回 nginx 404) | 同左 |
| MQTT Broker | 本地 uemqx(192.168.5.44:1883) | 本地 uemqx(`139.9.60.86:5000/uemqx:v1`) |
**容器挂载**(与 5.60 一致的 bind mount):
```
/data/third_party/plat-auto-test/backend -> /app
/data/third_party/plat-auto-test/frontend/dist -> /app/frontend/dist
/data/third_party/plat-auto-test/data -> /app/data
/data/third_party/plat-auto-test/logs -> /app/logs
```
---
#### ② 部署内容
**新增脚本**`backend/scripts/deploy_44_202.py`(paramiko SFTP,参数化 `--host [44|202|all]` / `--no-backend` / `--no-frontend`,同大小跳过)
| 步骤 | 说明 |
|------|------|
| 后端文件 | 43 个文件清单增量上传(同大小跳过)→ 实传 29 个,14 个跳过 |
| 前端 dist | `rm -rf` 后全量重新上传(约 90 个文件) |
| 容器重启 | `docker restart plat-auto-test-app` |
| 健康检查 | 轮询 `curl :8081/health` 直至返回 JSON(外网 80 端口是 nginx 404,需 8081 直连) |
**上传的后端文件覆盖模块**
- **性能测试**:performance(models / routers / schemas / services)、database.py 指标增强列
- **设备模拟**:device_sim_service.py(并行化 + per-thread engine)、mqtt_manager.py(on_disconnect 修复)
- **基础功能**:cases / executions / reports / cleanup / batch / modules / dependencies 路由 + services
- **执行引擎**:playwright_executor.py(146KB)
---
#### ③ 验证结果
| 验证项 | 5.44 | 5.202 |
|--------|------|-------|
| 容器状态 | ✅ `Up (healthy)` | ✅ `Up (healthy)` |
| :8081/health | ✅ `{"status":"healthy","service":"平台自动化测试系统","version":"1.0.0"}` | ✅ 同左 |
| :8081/api/device-sim/devices/stats | ✅ total=1000(door 500 + paperless 500) | ✅ total=500(door 500) |
| 容器内服务文件确认 | ✅ `performance_service.py` 等最新代码在位 | ✅ 同左 |
---
#### ④ 注意事项
- **5.202 的 `app/services/` 下未见 `door_token_client.py` / `paperless_simulator.py`**:这两个文件实际位于 `app/simulators/` 目录,本次部署脚本清单未包含。由于两台的设备环境配置均未设置 `token_api_host`(模拟器跳过 token 获取直接走 MQTT),**不影响正常运行**。后续如需启用门口屏/无纸化 token 功能,需把 `app/simulators/` 下这两个文件也补传。
- **访问端口**:5.44/5.202 平台的 80 端口是业务 unginx(非本平台),平台访问走 **8081**(如 `http://192.168.5.44:8081/`),与 5.60(`http://192.168.5.60/` 直连 80)不同。
- 5.44 的 MQTT Broker 存在闪断问题(rc=7,见会话 J/L),属 Broker 端问题、非 app 代码缺陷,设备模拟功能在 5.44/5.202 上如遇启动卡顿可参考会话 K 的并行化处理。
**修改文件清单(本次会话)**:仅新增 3 个探查/部署/验证脚本(`check_mounts_44_202.py` / `deploy_44_202.py` / `verify_44_202.py`,均 gitignored),业务代码零改动——纯部署会话。
**待办**
- 如需在 5.44/5.202 启用门口屏/无纸化 token 功能,补传 `app/simulators/door_token_client.py``paperless_simulator.py`
- 5.44 环境持续观察 MQTT Broker 闪断(Broker 端问题)
---
### 2026-08-21 会话 M:设备模拟未部署改动部署到 5.60(已部署并验证通过) ### 2026-08-21 会话 M:设备模拟未部署改动部署到 5.60(已部署并验证通过)
**会话目标**:将自上次部署 commit `695460eb` 以来累积的设备模拟改动全部部署到 5.60 生产环境并验证。 **会话目标**:将自上次部署 commit `695460eb` 以来累积的设备模拟改动全部部署到 5.60 生产环境并验证。
......
...@@ -101,12 +101,32 @@ async def init_db() -> None: ...@@ -101,12 +101,32 @@ async def init_db() -> None:
>>> import asyncio >>> import asyncio
>>> asyncio.run(init_db()) >>> asyncio.run(init_db())
""" """
# 导入所有模型注册到 Base.metadata(必须在 create_all 之前)
from app.models import device_sim # noqa: F401
from app.models import performance # noqa: F401
from app.models import performance_output # noqa: F401
from app.models import api_preset # noqa: F401
from app.models import scheduled_task # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# SQLite 对已存在的表不会自动加列,需手动补齐 # SQLite 对已存在的表不会自动加列,需手动补齐
await _ensure_columns(conn) await _ensure_columns(conn)
logger.info("数据库表结构初始化完成") logger.info("数据库表结构初始化完成")
# 存量数据回填:将现有任务的最新执行结果转为执行记录(幂等,仅执行一次)
try:
from app.services.performance_service import PerformanceService
async with async_session_maker() as session:
service = PerformanceService(session)
migrated = await service._migrate_legacy_executions()
await session.commit()
if migrated > 0:
logger.info(f"存量执行数据回填完成:迁移 {migrated} 条执行记录")
except Exception as e:
# 回填失败不阻断启动,下次启动会重试(幂等设计)
logger.warning(f"存量执行数据回填失败(不影响启动,下次重试): {e}")
async def _ensure_columns(conn) -> None: async def _ensure_columns(conn) -> None:
""" """
...@@ -225,6 +245,8 @@ async def _ensure_columns(conn) -> None: ...@@ -225,6 +245,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "csv_variable_mapping", "JSON"), ("performance_tasks", "csv_variable_mapping", "JSON"),
("performance_tasks", "api_summary", "JSON"), ("performance_tasks", "api_summary", "JSON"),
# 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录) # 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录)
# 性能测试:快照关联执行记录 ID(旧库升级,执行跳转闭环用)
("performance_snapshots", "execution_id", "VARCHAR(64) DEFAULT NULL"),
] ]
def _do_ensure(sync_conn) -> None: def _do_ensure(sync_conn) -> None:
......
此差异已折叠。
此差异已折叠。
...@@ -41,26 +41,41 @@ async def list_outputs( ...@@ -41,26 +41,41 @@ async def list_outputs(
获取任务输出列表(分页) 获取任务输出列表(分页)
支持按 task_id 和 key 过滤,用于 REF 引用验证和前端展示。 支持按 task_id 和 key 过滤,用于 REF 引用验证和前端展示。
"""
query = select(PerformanceTaskOutput)
count_query = select(func.count()).select_from(PerformanceTaskOutput)
注意:value 列可能存数千个捕获值(数 MB JSON),ORDER BY 若带整行
会触发 MySQL filesort 爆 sort buffer(Out of sort memory)。
故采用两段式:先只按 id 轻量排序分页,再按 id 回查整行。
"""
conditions = []
if task_id: if task_id:
query = query.where(PerformanceTaskOutput.task_id == task_id) conditions.append(PerformanceTaskOutput.task_id == task_id)
count_query = count_query.where(PerformanceTaskOutput.task_id == task_id)
if key: if key:
query = query.where(PerformanceTaskOutput.key == key) conditions.append(PerformanceTaskOutput.key == key)
count_query = count_query.where(PerformanceTaskOutput.key == key)
count_query = select(func.count()).select_from(PerformanceTaskOutput)
if conditions:
count_query = count_query.where(*conditions)
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
query = ( # 第一段:轻量排序(只取 id,value 大列不进 sort buffer)
query.order_by(desc(PerformanceTaskOutput.created_at)) id_query = select(PerformanceTaskOutput.id)
if conditions:
id_query = id_query.where(*conditions)
id_query = (
id_query.order_by(desc(PerformanceTaskOutput.created_at))
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
result = await db.execute(query) ids = [row[0] for row in (await db.execute(id_query)).all()]
items = list(result.scalars().all())
# 第二段:按 id 回查整行,并按第一段顺序还原分页
items: list = []
if ids:
rows = await db.execute(
select(PerformanceTaskOutput).where(PerformanceTaskOutput.id.in_(ids))
)
by_id = {o.id: o for o in rows.scalars().all()}
items = [by_id[i] for i in ids if i in by_id]
return PerformanceTaskOutputListResponse( return PerformanceTaskOutputListResponse(
total=total, total=total,
...@@ -92,29 +107,37 @@ async def get_task_outputs( ...@@ -92,29 +107,37 @@ async def get_task_outputs(
获取某个任务的所有输出 获取某个任务的所有输出
便捷接口,直接按 task_id 查询,可指定 key 过滤特定字段。 便捷接口,直接按 task_id 查询,可指定 key 过滤特定字段。
同 list_outputs:两段式查询避免大 value 列参与 ORDER BY。
""" """
query = select(PerformanceTaskOutput).where( conditions = [PerformanceTaskOutput.task_id == task_id]
PerformanceTaskOutput.task_id == task_id if key:
) conditions.append(PerformanceTaskOutput.key == key)
count_query = ( count_query = (
select(func.count()) select(func.count())
.select_from(PerformanceTaskOutput) .select_from(PerformanceTaskOutput)
.where(PerformanceTaskOutput.task_id == task_id) .where(*conditions)
) )
if key:
query = query.where(PerformanceTaskOutput.key == key)
count_query = count_query.where(PerformanceTaskOutput.key == key)
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
query = ( # 第一段:轻量排序(只取 id)
query.order_by(PerformanceTaskOutput.created_at.asc()) id_query = (
select(PerformanceTaskOutput.id)
.where(*conditions)
.order_by(PerformanceTaskOutput.created_at.asc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
result = await db.execute(query) ids = [row[0] for row in (await db.execute(id_query)).all()]
items = list(result.scalars().all())
# 第二段:按 id 回查整行,按第一段顺序还原分页
items: list = []
if ids:
rows = await db.execute(
select(PerformanceTaskOutput).where(PerformanceTaskOutput.id.in_(ids))
)
by_id = {o.id: o for o in rows.scalars().all()}
items = [by_id[i] for i in ids if i in by_id]
return PerformanceTaskOutputListResponse( return PerformanceTaskOutputListResponse(
total=total, total=total,
......
...@@ -361,11 +361,88 @@ class PerformanceRunResponse(BaseModel): ...@@ -361,11 +361,88 @@ class PerformanceRunResponse(BaseModel):
"""执行性能测试任务响应""" """执行性能测试任务响应"""
message: str message: str
task_id: str task_id: str
execution_id: Optional[str] = Field(None, description="执行记录ID")
status: str status: str
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 执行历史 ====================
class PerformanceExecutionListItem(BaseModel):
"""执行历史列表项"""
id: str = Field(..., description="执行ID")
task_id: str = Field(..., description="任务ID")
task_name: str = Field(..., description="任务名")
project_id: Optional[str] = Field(None, description="项目ID")
status: str = Field(..., description="状态")
start_time: Optional[str] = Field(None, description="开始时间")
end_time: Optional[str] = Field(None, description="结束时间")
duration_actual: float = Field(0.0, description="实际时长(秒)")
total_requests: int = Field(0, description="总请求数")
actual_tps: float = Field(0.0, description="实际TPS")
avg_response_time: float = Field(0.0, description="平均响应时间(ms)")
p95_response_time: float = Field(0.0, description="P95响应时间(ms)")
error_rate: float = Field(0.0, description="错误率")
triggered_by: str = Field("manual", description="触发方式")
snapshot_count: int = Field(0, description="快照条数")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceExecutionListResponse(BaseModel):
"""执行历史列表响应"""
total: int = Field(..., description="总数")
items: List[PerformanceExecutionListItem] = Field(..., description="列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceExecutionDetailResponse(BaseModel):
"""执行历史详情响应"""
id: str = Field(..., description="执行ID")
task_id: str = Field(..., description="任务ID")
task_name: str = Field(..., description="任务名")
project_id: Optional[str] = Field(None, description="项目ID")
status: str = Field(..., description="状态")
error_message: Optional[str] = Field(None, description="失败原因")
start_time: Optional[str] = Field(None, description="开始时间")
end_time: Optional[str] = Field(None, description="结束时间")
duration_actual: float = Field(0.0, description="实际时长(秒)")
total_requests: int = Field(0, description="总请求数")
success_count: int = Field(0, description="成功请求数")
fail_count: int = Field(0, description="失败请求数")
error_rate: float = Field(0.0, description="错误率")
actual_tps: float = Field(0.0, description="实际TPS")
peak_tps: float = Field(0.0, description="峰值TPS")
min_response_time: float = Field(0.0, description="最小响应时间(ms)")
max_response_time: float = Field(0.0, description="最大响应时间(ms)")
avg_response_time: float = Field(0.0, description="平均响应时间(ms)")
p50_response_time: float = Field(0.0, description="P50响应时间(ms)")
p90_response_time: float = Field(0.0, description="P90响应时间(ms)")
p95_response_time: float = Field(0.0, description="P95响应时间(ms)")
p99_response_time: float = Field(0.0, description="P99响应时间(ms)")
std_dev: float = Field(0.0, description="标准差")
latency_avg: float = Field(0.0, description="平均延迟(ms)")
latency_min: float = Field(0.0, description="最小延迟(ms)")
latency_max: float = Field(0.0, description="最大延迟(ms)")
connect_time_avg: float = Field(0.0, description="平均连接时间(ms)")
connect_time_max: float = Field(0.0, description="最大连接时间(ms)")
apdex: float = Field(0.0, description="Apdex指数")
total_sent_bytes: int = Field(0, description="发送字节数")
total_received_bytes: int = Field(0, description="接收字节数")
resource_summary: Optional[Any] = Field(None, description="执行机资源汇总")
target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
transaction_summary: Optional[Any] = Field(None, description="事务汇总")
api_summary: Optional[Any] = Field(None, description="接口维度汇总")
snapshot_count: int = Field(0, description="快照条数")
triggered_by: str = Field("manual", description="触发方式")
batch_id: Optional[str] = Field(None, description="关联批次ID")
created_at: Optional[str] = Field(None, description="创建时间")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 快照 ==================== # ==================== 快照 ====================
class PerformanceSnapshotResponse(BaseModel): class PerformanceSnapshotResponse(BaseModel):
...@@ -472,6 +549,45 @@ class PerformanceReportResponse(BaseModel): ...@@ -472,6 +549,45 @@ class PerformanceReportResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceExecutionReportResponse(BaseModel):
"""执行记录报告响应(与 PerformanceReportResponse 结构一致)"""
summary: PerformanceReportSummary = Field(..., description="执行摘要")
metrics: PerformanceReportMetrics = Field(..., description="指标统计")
snapshots: List[PerformanceSnapshotResponse] = Field(default_factory=list, description="快照数据")
resource_summary: Optional[Any] = Field(None, description="执行机资源汇总")
target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
transaction_summary: Optional[Any] = Field(None, description="事务汇总")
api_summary: Optional[Any] = Field(None, description="接口维度汇总")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== AI 分析 ====================
class AiAnalysisCompareRequest(BaseModel):
"""多版本对比 AI 分析请求"""
execution_ids: List[str] = Field(..., min_length=2, description="待对比的执行记录 ID 列表(至少 2 个)")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class AiAnalysisResponse(BaseModel):
"""AI 分析响应"""
execution_id: str = Field("", description="执行记录 ID(对比分析时为逗号拼接)")
analysis_type: str = Field("single", description="分析类型: single / comparison")
overall_verdict: str = Field("", description="总体评价(Markdown)")
key_findings: List[Dict[str, Any]] = Field(default_factory=list, description="关键发现")
bottlenecks: List[Dict[str, Any]] = Field(default_factory=list, description="瓶颈分析")
suggestions: List[Dict[str, Any]] = Field(default_factory=list, description="优化建议")
resource_analysis: Optional[str] = Field(None, description="资源分析(Markdown)")
trend_analysis: Optional[Dict[str, Any]] = Field(None, description="趋势分析")
comparison: Optional[List[Dict[str, Any]]] = Field(None, description="多版本对比明细")
raw_response: Optional[str] = Field(None, description="Claude 原始响应(调试用)")
source: str = Field("ai", description="分析来源: ai / rule")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 列表 ==================== # ==================== 列表 ====================
class PerformanceTaskListResponse(BaseModel): class PerformanceTaskListResponse(BaseModel):
......
此差异已折叠。
...@@ -15,7 +15,8 @@ ...@@ -15,7 +15,8 @@
"marked": "^18.0.7", "marked": "^18.0.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
...@@ -1484,6 +1485,15 @@ ...@@ -1484,6 +1485,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/agent-base/-/agent-base-6.0.2.tgz",
...@@ -1563,6 +1573,19 @@ ...@@ -1563,6 +1573,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/chokidar/-/chokidar-5.0.0.tgz",
...@@ -1579,6 +1602,15 @@ ...@@ -1579,6 +1602,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://mirrors.cloud.tencent.com/npm/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/combined-stream/-/combined-stream-1.0.8.tgz",
...@@ -1598,6 +1630,18 @@ ...@@ -1598,6 +1630,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/csstype/-/csstype-3.2.3.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/csstype/-/csstype-3.2.3.tgz",
...@@ -1837,6 +1881,15 @@ ...@@ -1837,6 +1881,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz",
...@@ -2328,6 +2381,18 @@ ...@@ -2328,6 +2381,18 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/terser": { "node_modules/terser": {
"version": "5.49.0", "version": "5.49.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/terser/-/terser-5.49.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/terser/-/terser-5.49.0.tgz",
...@@ -2514,6 +2579,45 @@ ...@@ -2514,6 +2579,45 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "5.6.1", "version": "5.6.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/zrender/-/zrender-5.6.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/zrender/-/zrender-5.6.1.tgz",
......
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
"marked": "^18.0.7", "marked": "^18.0.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
......
...@@ -15,6 +15,8 @@ import type { ...@@ -15,6 +15,8 @@ import type {
PerformanceRunResponse, PerformanceRunResponse,
PerformanceSnapshotListResponse, PerformanceSnapshotListResponse,
PerformanceReportResponse, PerformanceReportResponse,
PerformanceExecutionListResponse,
PerformanceExecutionDetail,
CurlParseResult, CurlParseResult,
PerformanceProjectCreate, PerformanceProjectCreate,
PerformanceProjectUpdate, PerformanceProjectUpdate,
...@@ -26,6 +28,7 @@ import type { ...@@ -26,6 +28,7 @@ import type {
BatchDetailResponse, BatchDetailResponse,
ProjectReportResponse, ProjectReportResponse,
ProjectReportListResponse, ProjectReportListResponse,
AiAnalysisResponse,
} from '@/types/performance' } from '@/types/performance'
const BASE = '/api/performance' const BASE = '/api/performance'
...@@ -92,8 +95,12 @@ export function getSnapshots(id: string, params?: { ...@@ -92,8 +95,12 @@ export function getSnapshots(id: string, params?: {
} }
/** 获取报告 */ /** 获取报告 */
export function getReport(id: string): Promise<PerformanceReportResponse> { export function getReport(id: string, executionId?: string): Promise<PerformanceReportResponse> {
return request.get(`${BASE}/tasks/${id}/report`) const params: Record<string, any> = {}
if (executionId) {
params.execution_id = executionId
}
return request.get(`${BASE}/tasks/${id}/report`, { params })
} }
/** 获取任务执行结果 */ /** 获取任务执行结果 */
...@@ -101,6 +108,59 @@ export function getTaskResults(id: string): Promise<PerformanceTask> { ...@@ -101,6 +108,59 @@ export function getTaskResults(id: string): Promise<PerformanceTask> {
return request.get(`${BASE}/tasks/${id}/results`) return request.get(`${BASE}/tasks/${id}/results`)
} }
// ==================== 执行历史 ====================
/** 获取执行历史列表 */
export function listExecutions(params?: {
taskId?: string
projectId?: string
page?: number
pageSize?: number
}): Promise<PerformanceExecutionListResponse> {
const queryParams: Record<string, any> = { ...params }
if (params?.taskId !== undefined) {
queryParams.task_id = params.taskId
delete queryParams.taskId
}
if (params?.projectId !== undefined) {
queryParams.project_id = params.projectId
delete queryParams.projectId
}
if (params?.pageSize !== undefined) {
queryParams.page_size = params.pageSize
delete queryParams.pageSize
}
return request.get(`${BASE}/executions`, { params: queryParams })
}
/** 获取执行记录详情 */
export function getExecutionDetail(executionId: string): Promise<PerformanceExecutionDetail> {
return request.get(`${BASE}/executions/${executionId}`)
}
/** 获取执行记录报告 */
export function getExecutionReport(executionId: string): Promise<PerformanceReportResponse> {
return request.get(`${BASE}/executions/${executionId}/report`)
}
/** 获取执行记录快照 */
export function getExecutionSnapshots(executionId: string, params?: {
page?: number
pageSize?: number
}): Promise<PerformanceSnapshotListResponse> {
const queryParams: Record<string, any> = { ...params }
if (params?.pageSize !== undefined) {
queryParams.page_size = params.pageSize
delete queryParams.pageSize
}
return request.get(`${BASE}/executions/${executionId}/snapshots`, { params: queryParams })
}
/** 删除执行记录 */
export function deleteExecution(executionId: string): Promise<void> {
return request.delete(`${BASE}/executions/${executionId}`)
}
// ==================== WebSocket 实时监控 ==================== // ==================== WebSocket 实时监控 ====================
/** 创建 WebSocket 连接订阅性能测试实时数据 */ /** 创建 WebSocket 连接订阅性能测试实时数据 */
...@@ -290,3 +350,15 @@ export function getProjectReports(projectId: string, params?: { ...@@ -290,3 +350,15 @@ export function getProjectReports(projectId: string, params?: {
}): Promise<ProjectReportListResponse> { }): Promise<ProjectReportListResponse> {
return request.get(`${BASE}/projects/${projectId}/reports`, { params }) return request.get(`${BASE}/projects/${projectId}/reports`, { params })
} }
// ==================== AI 分析 ====================
/** 获取单次执行 AI 分析 */
export function getAiAnalysis(executionId: string): Promise<AiAnalysisResponse> {
return request.post(`${BASE}/executions/${executionId}/ai-analysis`)
}
/** 多版本对比 AI 分析 */
export function getAiComparison(executionIds: string[]): Promise<AiAnalysisResponse> {
return request.post(`${BASE}/ai-analysis/compare`, { executionIds })
}
\ No newline at end of file
...@@ -187,6 +187,8 @@ export interface TargetResourceData { ...@@ -187,6 +187,8 @@ export interface TargetResourceData {
loadAvg1: number loadAvg1: number
loadAvg5: number loadAvg5: number
loadAvg15: number loadAvg15: number
/** 目标机地址(仅合并报告汇总中有,实时采样无此字段) */
host?: string
/** Java 进程 CPU/内存占用明细 */ /** Java 进程 CPU/内存占用明细 */
javaProcesses?: JavaProcessSample[] | null javaProcesses?: JavaProcessSample[] | null
timestamp: number timestamp: number
...@@ -480,9 +482,81 @@ export interface PerformanceRunRequest { ...@@ -480,9 +482,81 @@ export interface PerformanceRunRequest {
export interface PerformanceRunResponse { export interface PerformanceRunResponse {
message: string message: string
taskId: string taskId: string
/** 执行记录ID(非阻塞执行立即返回,前端凭此跳转监控页) */
executionId?: string | null
status: string status: string
} }
// ==================== 执行历史 ====================
/** 执行历史列表项 */
export interface PerformanceExecutionListItem {
id: string
taskId: string
taskName: string
projectId: string | null
status: PerfStatus
startTime: string | null
endTime: string | null
durationActual: number
totalRequests: number
actualTps: number
avgResponseTime: number
p95ResponseTime: number
errorRate: number
triggeredBy: string
snapshotCount: number
}
/** 执行历史列表响应 */
export interface PerformanceExecutionListResponse {
total: number
items: PerformanceExecutionListItem[]
}
/** 执行历史详情 */
export interface PerformanceExecutionDetail {
id: string
taskId: string
taskName: string
projectId: string | null
status: PerfStatus
errorMessage: string | null
startTime: string | null
endTime: string | null
durationActual: number
totalRequests: number
successCount: number
failCount: number
errorRate: number
actualTps: number
peakTps: number
minResponseTime: number
maxResponseTime: number
avgResponseTime: number
p50ResponseTime: number
p90ResponseTime: number
p95ResponseTime: number
p99ResponseTime: number
stdDev: number
latencyAvg: number
latencyMin: number
latencyMax: number
connectTimeAvg: number
connectTimeMax: number
apdex: number
totalSentBytes: number
totalReceivedBytes: number
resourceSummary?: ResourceSummary | null
targetResourceSummary?: TargetResourceSummary | null
transactionSummary?: TransactionSummary | null
apiSummary?: ApiSummaryData[] | null
snapshotCount: number
triggeredBy: string
batchId: string | null
createdAt: string | null
}
/** 快照数据 */ /** 快照数据 */
export interface PerformanceSnapshot { export interface PerformanceSnapshot {
id: number id: number
...@@ -595,6 +669,8 @@ export interface PerformanceReportResponse { ...@@ -595,6 +669,8 @@ export interface PerformanceReportResponse {
export interface PerfWsSnapshot { export interface PerfWsSnapshot {
type: 'perf_snapshot' type: 'perf_snapshot'
taskId: string taskId: string
/** 执行记录ID(非阻塞执行时附带) */
executionId?: string | null
data: { data: {
elapsed: number elapsed: number
tps: number tps: number
...@@ -628,6 +704,8 @@ export interface PerfWsSnapshot { ...@@ -628,6 +704,8 @@ export interface PerfWsSnapshot {
export interface PerfWsComplete { export interface PerfWsComplete {
type: 'perf_complete' type: 'perf_complete'
taskId: string taskId: string
/** 执行记录ID(非阻塞执行时附带) */
executionId?: string | null
status: string status: string
data: Record<string, any> data: Record<string, any>
} }
...@@ -866,3 +944,20 @@ export interface ProjectReportListResponse { ...@@ -866,3 +944,20 @@ export interface ProjectReportListResponse {
total: number total: number
items: ProjectReportResponse[] items: ProjectReportResponse[]
} }
// ==================== AI 分析 ====================
/** AI 分析响应 */
export interface AiAnalysisResponse {
executionId: string
analysisType: 'single' | 'comparison'
overallVerdict: string
keyFindings: { type: string; description: string; severity: string; evidence?: string }[]
bottlenecks: { type: string; location: string; severity: 'high' | 'medium' | 'low'; description: string; evidence: string }[]
suggestions: { priority: 'P0' | 'P1' | 'P2'; content: string; expectedEffect: string }[]
resourceAnalysis?: string
trendAnalysis?: Record<string, any>
comparison?: Record<string, any>[]
rawResponse?: string
source: 'ai' | 'rule'
}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论